OpenTofu + Ansible that provision and maintain a self-hosted task tracker on the Pit homelab.
  • Shell 41.5%
  • Makefile 27%
  • HCL 26.8%
  • Jinja 4.7%
Find a file
2026-10-09 21:58:30 +00:00
.agents/skills agents: vendor the engineering skills and carry their license 2026-10-09 10:39:01 +02:00
docs docs(tracker): project identifier INFRATRACK -> TR (!12) 2026-10-09 21:58:30 +00:00
npm npm: close the review findings 2026-10-09 09:30:34 +02:00
vikunja npm: publish the Tracker from the Edge 2026-10-09 09:03:34 +02:00
.env.example npm: publish the Tracker from the Edge 2026-10-09 09:03:34 +02:00
.gitignore agents: vendor the engineering skills and carry their license 2026-10-09 10:39:01 +02:00
AGENTS.md agents: vendor the engineering skills and carry their license 2026-10-09 10:39:01 +02:00
GLOSSARY.md docs(domain): address review on PR #11 — ADR 0002 records the full decision 2026-10-09 17:20:58 +02:00
LICENSE Initial commit 2026-10-08 14:45:40 +00:00
Makefile npm: close the review findings 2026-10-09 09:30:34 +02:00
README.md docs(domain): address review on PR #11 — ADR 0002 records the full decision 2026-10-09 17:20:58 +02:00
skills-lock.json agents: vendor the engineering skills and carry their license 2026-10-09 10:39:01 +02:00

infra-tracker

OpenTofu + Ansible that provision and maintain a self-hosted task tracker on the Pit homelab.

The repo name and the stack are deliberately generic so the tool can be swapped later; the folder is named vikunja/ because that is what runs today.

Layout

Organised by feature, not by tool: each feature folder holds its own layers. One root .env carries the secrets for the whole repo.

Makefile                  root: the guest, service and edge targets
.env                      root: SECRETS — gitignored, never committed
vikunja/guest/            OpenTofu: the LXC guest stack
vikunja/service/          Ansible: the native install on that guest
npm/                      OpenTofu: the Edge stack (the NPM proxy host)

The guest stack (vikunja/guest/)

One unprivileged Debian 13 LXC on the Proxmox cluster. Ansible then installs the task tracker on it natively.

Thing Value
Node burns
vmid 142
hostname vikunja
IP 10.12.0.142/24, gateway 10.12.0.1
Bridge vmbr1
Rootfs datastore fast-storage
Cores / memory / swap / disk 2 / 2048 / 512 / 16 GB
unprivileged / nesting true / true
Provider bpg/proxmox, ~> 0.77

Nesting is true and is not optional. Modern systemd requires it; Debian 13 with nesting disabled comes up degraded (services failing 243/CREDENTIALS, empty console).

The base image: declared, then imported once

The Debian 13 template on the synology datastore is managed by infra-forge. This stack declares the same proxmox_download_file resource (same URL and checksum), but the file already exists on the node, so import it once before the first apply:

tofu import proxmox_download_file.debian_13_base \
  burns/synology:vztmpl/debian-13-standard_13.6-1_amd64.tar.zst

The <node>/<datastore>:<content_type>/<file> shape is required; the import fails without the node prefix.

The service stack (vikunja/service/)

Ansible installs Vikunja natively on the guest: no container, no orchestration.

Thing Value
Version Vikunja v2.7.0, downloaded checksum-first
Binary /opt/vikunja/vikunja (one binary serves the API and the frontend)
Config /etc/vikunja/config.yml, 0640 root:vikunja, passed via --config
Home /var/lib/vikunja (files/basepath under it)
Database PostgreSQL 17 on the same guest (package postgresql-17, asserted), db and role vikunja
Unit vikunja.service, sandboxed (ProtectSystem=strict, NoNewPrivileges)
Public URL https://vikunja.thepit.space/
Logs the journal (log.standard defaults to stdout)
Client IP ipextractionmethod: xff, trusting the Edge (10.12.0.113) via trustedproxies

The role is idempotent: a second run changes nothing. user create is not, so the role guards it with user list --email and only creates what is missing.

Secrets

The prod Vault holds the values, encrypted, in vikunja/service/inventories/prod/group_vars/vikunja/vault.yml (committed as ciphertext). It sits beside the prod Inventory so a test run selects the dev/ci Vault instead (ADR-0002):

Value Key
PostgreSQL password vault_vikunja_db_password
Service JWT secret vault_vikunja_service_secret
Account passwords vault_vikunja_account_passwords (by username)
SMTP password vault_vikunja_smtp_password

The prod Vault's password lives in the root .env (ANSIBLE_VAULT_PASSWORD); the Makefile derives the gitignored vikunja/service/vault-pass from it, so the operator keeps one secrets file. The dev/ci Vault has its own password, held only in the CI secret store.

The mailer is on: vault_vikunja_smtp_password is set, so mailer.enabled resolves true. Empty that value to turn mail off again.

Account passwords are create-time only. Changing one in the Vault and re-running does not change the live password, because the role only creates missing accounts (and the CLI has no non-interactive password change).

Adding an account is a two-file edit — the entry in roles/vikunja/defaults/main.yml (vikunja_accounts) and its password under vault_vikunja_account_passwords in the Vault. The role asserts every listed account has a password, so a missing one fails with a clear message instead of crashing mid-loop.

cd vikunja/service
ansible-vault edit inventories/prod/group_vars/vikunja/vault.yml  # needs vault-pass present

The Edge stack (npm/)

One Nginx Proxy Manager proxy host publishes the Tracker from the WAN. It follows infra-forge's Edge: only the host named in edge.tfvars is managed, TLS is looked up rather than created, and everything else in NPM is left alone.

Thing Value
Published site https://vikunja.thepit.space/
Target 10.12.0.142:3456 (the Tracker Guest), scheme http
TLS NPM certificate looked up by domain; highest id wins
Host flags SSL forced, HTTP/2, block-exploits, websockets; no HSTS, no caching
Provider Sander0542/nginxproxymanager, ~> 1.4

The certificate is a prerequisite, and a UI step

The stack looks up the Let's Encrypt certificate for vikunja.thepit.space; it never creates one (this NPM build rejects the provider's certificate_letsencrypt create). Request the cert once in the NPM UI (Let's Encrypt, HTTP-01); NPM auto-renews it thereafter. If it is missing, make edge-plan fails on purpose with a precondition naming the domain, rather than publishing the host without TLS.

Running it

make edge-plan   # show the plan; mutates nothing
make edge        # plan, render, confirm, apply
make verify      # external check: https 200, http 301

Using it

cp .env.example .env      # fill in the Proxmox endpoint, API token, vault password, NPM creds
make guest-plan           # show the guest plan; mutates nothing
make guest                # plan, render, confirm, apply
make service-check        # rehearse the install with --check --diff
make service              # check, then install for real
make edge-plan            # show the edge plan; mutates nothing
make edge                 # plan, render, confirm, apply
make verify               # external check: https 200, http 301
make fmt                  # tofu fmt -recursive
make validate             # validate every stack (guest and edge)

Secrets live in the root .env (PROXMOX_VE_ENDPOINT, PROXMOX_VE_API_TOKEN, ANSIBLE_VAULT_PASSWORD, NGINXPROXYMANAGER_URL, NGINXPROXYMANAGER_USERNAME, NGINXPROXYMANAGER_PASSWORD). It is gitignored and never committed.

Conventions

  • Work on a branch, open a PR. Nothing lands directly on main.
  • Never commit our addresses: the DDNS hostname and the external IP stay out of the repo. Public service domains and upstream mirrors are fine.