heyer.systems

Develop

Build an app

From an empty repository to an app with container, VM image and update – in six steps.

The idea

An iac app only describes its target state – as an Ansible role: packages, users, files, configuration. Plus a profile (iac.json) with ports, services and health check. iac-core does everything else:

  • create the container and the VM, plus the one-liners ct.sh, vm.sh and adopt.sh
  • build and test the VM image, the CI
  • firewall, starting services, health check, update, modules

That is why an app repository has only a few files:

iac-myapp/
├── iac.json            profile
├── tasks/main.yml      the target state (Ansible)          ← what matters
├── handlers/main.yml   restarts
├── templates/, files/  configuration files, systemd units
├── tests/smoke.sh      checks only this app needs
├── README.md, CHANGELOG.md, AGENTS.md (+ pointers for AI tools)
└── .forgejo/workflows/ lint.yml, image.yml – unchanged from the template

Ansible runs locally inside the guest – no controller, no inventory, no SSH from outside.

1. Prepare

mkdir -p ~/repositories/iac && cd ~/repositories/iac
git clone https://git.heyer.systems/iac/iac-core.git
git clone https://git.heyer.systems/iac/iac-example.git

Locally you need shellcheck, jq, ansible-core, systemd (lint) and Docker (testing without Proxmox). Read iac-example first, in the order given in its README: a small app in which every feature appears exactly once.

2. Create the skeleton

iac-core/tools/new-app.sh myapp        # creates iac-myapp/, never overwrites anything

The skeleton is mandatory, also when an AI tool writes the repository: do not create the files by hand. .forgejo/workflows/, AGENTS.md and the pointers for AI tools stay as the template creates them. For a repository you already started, new-app.sh adds the missing files.

3. Profile: iac.json

{
  "name": "myapp",
  "title": "My App",
  "description": "One sentence for the website.",
  "category": "web",
  "tags": ["wiki", "markdown"],
  "upstream": { "type": "debian", "repo": "myapp" },
  "supports": ["lxc", "vm"],
  "defaults": {
    "lxc": { "cpu": 1, "ram": 512, "disk": 4 },
    "vm":  { "cpu": 1, "ram": 1024, "disk": 8 }
  },
  "ports": [{ "port": 80, "proto": "tcp", "description": "Web", "access": "public" }],
  "services": ["myapp"],
  "health": { "url": "http://127.0.0.1/" },
  "access": "Web: http://{ip}/"
}

ports becomes the firewall, iac-core starts and checks services, health must answer after every installation. category and tags place the app in the catalog. All fields: app reference.

4. Target state: tasks/main.yml

Plain Ansible – only with the built-in modules (ansible.builtin.*):

- name: Package myapp
  ansible.builtin.apt:
    name: myapp
    install_recommends: false

- name: Configuration
  ansible.builtin.template:
    src: config.yml.j2              # templates/config.yml.j2
    dest: /etc/myapp/config.yml
    owner: root
    group: root
    mode: "0644"
  notify: Restart myapp             # handlers/main.yml

Plus the handler in handlers/main.yml:

- name: Restart myapp
  ansible.builtin.systemd_service:
    name: myapp
    state: restarted
    daemon_reload: true
  when: not iac_build               # no systemd in the image build

Three rules, then it fits:

  1. Idempotent – a second run changes nothing. Using modules instead of command/shell gives you that for free.
  2. Restarts only via notify – so an update without changes does not interrupt the service.
  3. You do not start services yourself – iac-core does that for everything in services.

Software from outside Debian (GitHub release, own download page)? The role iac_version decides which version is installed, the download is verified against the release’s checksum: versions.

If the app has a login, it is usable right after the installation. An open setup wizard on the network is not. The app creates the first user with a password from secrets, access says where it is: first login.

5. Test

iac-core/tools/lint.sh iac-myapp            # typos, wrong fields, Ansible syntax, sandbox
iac-core/tools/dev-container.sh iac-myapp   # install + smoke test, without Proxmox
iac-core/tools/dev-container.sh iac-myapp   # second run: idempotent? (changed=1)

Look inside: docker exec -it iac-dev-myapp bash, log in /var/log/iac/iac.log.

The app is only done when these points were run, not only written:

  • lint.sh without errors – including the sandbox of the systemd units (pattern: iac-netbox/files/netbox.service; exception only with # iac: no-sandbox – <reason>)
  • dev-container.sh twice: smoke test green, changed=1 in the second run
  • right after the installation all ports are open and health answers, without anyone setting something up in the browser
  • with a login: logging in with the hint from access works
  • not from Debian: role iac_version, download with checksum. Installed with IAC_APP_VERSION=<older version>, the update path (app reference) really moves to the latest
  • with vm in supports: .forgejo/workflows/image.yml exists (from the template)

6. Publish

  1. Create the repository iac-myapp in the organisation iac (public) and push.
  2. The CI checks every push. A push to main also builds the VM image, starts it in a test VM and only publishes it if all checks pass.
  3. The website reads all public iac-* repositories every hour. Then the app page with the generator exists, and the one-liners /myapp/ct.sh and /myapp/vm.sh.

Next: app reference – all fields, variables, helper roles and common mistakes.