Develop
App reference
All fields of iac.json, the variables and helper roles of iac-core, tests, CI and common mistakes.
How an installation runs
Every way – container, image build, first boot of a VM, update, adoption – ends up at the same
playbook inside the guest:
Proxmox host Guest (LXC, VM or image build)
──────────── ───────────────────────────────────────────────────────
ct.sh ──── pct exec ──────────▶ bin/iac-install → ansible-playbook site.yml
vm.sh ──── ready image ───────▶ (ran in the chroot during the image build, again on first boot)
update → Debian upgrade → bin/iac-install (iac_mode=update)
adopt.sh → tasks/adopt_check.yml → backup
→ bin/iac-install (iac_mode=adopt)
site.yml:
iac_facts iac.json, instance, settings, secrets, modules
iac_baseline SSH, base packages, base firewall rules, update
iac_modules PostgreSQL, Valkey, …
YOUR APP tasks/main.yml ← this is what you write
iac_finish handlers (restarts) → firewall from "ports" → start "services"
→ check "health" → /etc/iac/instance.json
ct.sh, vm.sh and adopt.sh only live in iac-core (entry/). The website serves them for
every app with its name. adopt.sh exists as soon as the app has tasks/adopt_check.yml.
iac.json
| Field | Meaning |
|---|---|
name | lowercase letters, digits, -. The repository is called iac-<name>, the one-liners /<name>/ct.sh |
title, description | name and one sentence (English) for the website and the dialog |
category | category in the catalog: ai, automation, database, development, documentation, helpdesk, infrastructure, networking, security or web |
tags | keywords for search and filters, lowercase with -, e.g. ["dns", "ad-blocking"] |
website | project website of the software |
upstream | where the version comes from: {"type": "debian", "repo": "<package>"}, {"type": "apt", "repo": "<package>"} (third-party repository iac-core/apt/<package>.sources, e.g. Zammad), {"type": "github-release", "repo": "<owner/repo>"}, {"type": "npm", "repo": "<package>"}, {"type": "url", "url": "https://…"} (first line = version) or {"type": "other", "version": "…"}. With "major_lock": true, update stays in the installed major version |
supports | lxc, vm (with VM image), docker (with docker-compose.yml in the repository) |
defaults | resources per platform: cpu, ram (MiB, at least 512), disk (GiB). The boot test uses the VM RAM (at most 2.5 GiB) |
image.size | size of the root partition in the VM image, e.g. "8GiB" (default 3GiB); the disk grows when the guest is created |
ports | the firewall is built from this: port, proto (tcp/udp), description, access (public = everyone, private = private networks only) |
services | systemd units of the app: iac-core enables and starts them, the smoke test checks them |
health | url that must answer after every installation; status = allowed HTTP codes (default [200]) |
secrets | generated once per instance, never in the image: a name or {"name": …, "length": 32} → iac_settings.<name> |
settings | settings when creating the guest (var_<name>): name, title, description, secret → iac_settings.<name> |
modules | only what the app needs: text = required module, {"name": …, "default": true} = preselected, "default": false withdraws a preselection of the catalog (e.g. auto-update). All other modules can be chosen anyway (modules) |
adopt.backup | paths adopt.sh backs up before the adoption |
access | hint after creating the guest, {ip} is replaced |
icon | logo on the website: slug at selfh.st/icons (default: name) |
hidden | true = does not appear on the website |
The complete schema: iac-core/schema/iac.schema.json.
Variables
iac-core sets these variables, your tasks may read them:
| Variable | Content |
|---|---|
iac_build | true in the image build (chroot: no service runs, no secrets, no database) |
iac_mode | install, update or adopt |
iac_platform | lxc or vm |
iac_settings | settings and secrets of the instance, e.g. iac_settings.myapp_db_password |
iac_modules_active | active modules |
iac_json | the whole iac.json |
iac_app_version_pin | version the image build asks for (otherwise empty) |
iac_upstream_latest | latest version according to upstream (only queried when needed) |
All of them with explanations: header of iac-core/ansible/site.yml.
Versions
Debian packages: with "upstream": {"type": "debian", "repo": "<package>"} iac-core reads the
version itself. The app does not have to do anything.
Everything else (GitHub release, own download page): the role iac_version decides what is
installed. You only tell it how to find out the installed version:
- name: Version to install
ansible.builtin.include_role:
name: iac_version
vars:
iac_version_command: /usr/local/bin/myapp --version
iac_version_regex: 'v?([0-9.]+)' # optional: first group = version
- name: "Install myapp {{ iac_version_target }}"
when: iac_version_target != iac_version_installed
block:
- name: Download (checksum from the release)
ansible.builtin.get_url:
url: "https://github.com/vendor/myapp/releases/download/v{{ iac_version_target }}/myapp.tar.gz"
dest: /tmp/myapp.tar.gz
checksum: "sha256:https://github.com/vendor/myapp/releases/download/v{{ iac_version_target }}/SHA256SUMS"
mode: "0644"
# … unpack, install, notify: Restart myapp
| Situation | iac_version_target |
|---|---|
| not installed | the image’s version (iac_app_version_pin) or the latest |
update (and adoption) | the latest if it is newer – with "major_lock": true only within the major version, a new major version is reported |
| otherwise | the installed one |
Two things are mandatory:
- Checksum:
checksum:with the release’s checksum file. No download without a checksum. If a project publishes no checksums, settle that before building the app. - Only install on a new version:
when: iac_version_target != iac_version_installed, file to/tmpand delete it afterwards. Withoutchecksum,get_urldoes not download an existing file again – a fixed target like/var/cache/myapp.tar.gzwould unpack the old archive on everyupdate.
iac_version_installed is empty as long as nothing is installed. The role sets the version for
instance.json itself. Examples: iac-traefik (binary, update with rollback), iac-netbox (release
per directory), iac-ai-workstation.
Helper roles
Enable and start a service, also in the image build (there only enable):
- ansible.builtin.include_role:
name: iac_service
vars:
iac_service: myapp-worker
You do not need this for the services in services – iac-core starts those itself.
Database: postgresql in modules, then:
- name: Database
ansible.builtin.include_role:
name: postgresql
tasks_from: database
vars:
postgresql_db: myapp
postgresql_user: myapp
postgresql_password: "{{ iac_settings.myapp_db_password }}"
when: not iac_build
With MariaDB instead of PostgreSQL: module mariadb, tasks_from: database with mariadb_db,
mariadb_user and mariadb_password.
Files in the guest
- What your tasks place with
copy/templatebelongs to iac and is reconciled on every run. Such files start with# managed by iac – do not edit. - Changes by users go into files iac does not touch – plan a
localfile for that (iac-netbox:/etc/netbox/configuration.local.py). - You do not need a firewall file – it is built from
ports. - Secrets never go into the repository:
secretsiniac.json, in the template{{ iac_settings.name }}, tasks that use them getno_log: true.
First login
If the app has a login, it must be usable right after the installation:
- Password as a secret in
iac.json, e.g.{"name": "myapp_admin_password", "length": 24}. - The tasks create the first user with it, only as long as there is no admin yet – iac never
touches a changed password or your own admins.
no_log: true,when: not iac_build. accessnames the user and where to find the password; the app page (“Then:”) andct.shshow it:
"access": "Web: http://{ip}:8000/ – login: admin, password in the guest: grep myapp_admin_password /etc/iac/settings.env"
A setup wizard in the browser is not enough: whoever opens it first on the network decides the
admin, and often the service only listens on its ports after it – then the port check, smoke test
and boot test fail. Create a start configuration instead (force: false, so later changes stay)
that skips the wizard. Example: iac-netbox (netbox_admin_password).
Smoke test
iac-core already checks instance.json, the services, health and the ports in the firewall.
tests/smoke.sh only contains what is specific to your app:
check "API answers" 'curl -fsS http://127.0.0.1:9090/api/health | jq -e .ok'
check "runs as its own user" 'ps -o user= -C myapp | grep -qx myapp'
tests/smoke.sh is not a script of its own: iac-smoke reads it with source. So no
shebang, no set -e, no exit – only check "description" 'command' (header as in the
template, # shellcheck shell=bash).
check never aborts: all checks run, failed ones show the command and its output. The same test
runs in the boot test of every image and by hand in the guest
(bash /var/lib/iac/src/core/bin/iac-smoke).
Adoption
| File | When | What |
|---|---|---|
tasks/adopt_check.yml | before the question | check – change nothing. Refuse with ansible.builtin.fail and a reason |
tasks/adopt.yml | after the question and the backup (adopt.backup), before tasks/main.yml | move existing data into the iac layout |
Example: iac-traefik (adopts its own configuration as traefik.local.yaml). If it cannot be done
automatically, a guide belongs in docs/MIGRATION.md – the website links it.
Testing
Lint – typos, wrong fields, Ansible syntax, leftovers of old apps:
iac-core/tools/lint.sh iac-myapp
Without Proxmox – Docker container with systemd:
iac-core/tools/dev-container.sh iac-myapp # install + smoke test
iac-core/tools/dev-container.sh iac-myapp # again = like update
IAC_APP_VERSION=1.2.3 iac-core/tools/dev-container.sh iac-myapp # specific version
docker exec -e IAC_MODE=update iac-dev-myapp \
bash /var/lib/iac/src/core/bin/iac-install # update path of the app
The second run should show changed=1 (only instance.json) – then the app is idempotent. The
container is not a real LXC (privileged, no cloud-init), but it is enough for the app logic.
On Proxmox – a branch, before it goes to main:
IAC_APP_REF=my-branch bash -c "$(curl -fsSL https://iac.heyer.systems/myapp/ct.sh)"
In the guest, IAC_VERBOSE=1 bash /var/lib/iac/src/core/bin/iac-install shows all task results.
CI
The workflows are the same in all apps and consist of a single call. The steps live in iac-core
(ci/lint, ci/image):
| Workflow | When | What |
|---|---|---|
lint.yml | every push, every pull request | tools/lint.sh in a container |
image.yml | push to main, Mondays | lint → build image → boot in QEMU → boot test incl. iac-smoke → publish |
Only an image that passes the boot test is published. Hostname and size of the image come from
iac.json. For more (additional bootstrap-vz settings) there is the optional
image/manifest.yml.
Own modules
What several apps can use (database, cache, monitoring …) belongs in iac-core as a module – then every app can use it:
- Role
iac-core/ansible/roles/<module>/withmodule_state:presentinstalls and starts,absentremoves packages and files again – data stays. - Firewall rules of the module in
/etc/nftables.d/40-<module>.nft. - Entry in
iac-core/modules.json:title,description(English),supports(lxc,vm), optionallydefaultandicon(slug at selfh.st/icons, default: the module’s name).
A module for one app only lives in roles/<module>/ in the app repository. Structure and
examples: iac-core/ansible/roles/README.md.
Notes for the module’s page on this website go into iac-website/module-notes/<module>.md.
Common mistakes
| Symptom | Cause |
|---|---|
| Workflow never runs | file is in .forgejo/ instead of .forgejo/workflows/ |
Lint: module … does not exist | modules names a module that does not exist (yet) |
Lint: … belongs to the old layout | app.func, packages.list or rootfs/ copied from an old app |
Lint: ct.sh is not used any more | old entry scripts in the repository – delete them, the website serves them |
Lint: category: … | category missing or not one of the list above |
| Image build aborts in the chroot | a service is started or a database is used – when: not iac_build |
Service restarts on every update | restart as a task instead of a handler with notify |
neither the role iac_version nor iac_app_version | app from outside Debian without the role iac_version |
Lint: iac.json does not match the schema | e.g. "major_lock": 0 instead of true – fields exactly as in the schema |
update installs no new version | download to a fixed file without checksum – see versions |
| Port check fails after a fresh installation | service only listens after a setup wizard – see first login |
iac-smoke ends without a summary | set -e or exit in tests/smoke.sh – the file is sourced, an error then ends the whole test |
No VM image, vm.sh finds nothing | .forgejo/workflows/image.yml is missing or was changed – take the template |
Own changes gone after update | change in a managed by iac file instead of a local file |
item is undefined in a loop | loop over something that may be missing – set_fact first, then loop |
Ansible knowledge
Available is ansible-core from Debian, so the modules of ansible.builtin – no other
collections: documentation of the built-in modules.