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.shandadopt.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:
- Idempotent – a second run changes nothing. Using modules instead of
command/shellgives you that for free. - Restarts only via
notify– so anupdatewithout changes does not interrupt the service. - 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.shwithout errors – including the sandbox of the systemd units (pattern:iac-netbox/files/netbox.service; exception only with# iac: no-sandbox – <reason>) -
dev-container.shtwice: smoke test green,changed=1in the second run - right after the installation all
portsare open andhealthanswers, without anyone setting something up in the browser - with a login: logging in with the hint from
accessworks - not from Debian: role
iac_version, download with checksum. Installed withIAC_APP_VERSION=<older version>, the update path (app reference) really moves to the latest - with
vminsupports:.forgejo/workflows/image.ymlexists (from the template)
6. Publish
- Create the repository
iac-myappin the organisation iac (public) and push. - The CI checks every push. A push to
mainalso builds the VM image, starts it in a test VM and only publishes it if all checks pass. - The website reads all public
iac-*repositories every hour. Then the app page with the generator exists, and the one-liners/myapp/ct.shand/myapp/vm.sh.
Next: app reference – all fields, variables, helper roles and common mistakes.