Pact#
Deployment tool that embraces systemd.
Pact separates host bootstrap (Ansible, one-shot per host) from app deployment (rootless Podman Quadlets + bash, per App) on systemd-based VPS hosts. Deploy is idempotent and never destroys running containers, networks, or volumes. Teardown is explicit — it prompts for confirmation, checks for dependents, and never removes volumes.
Requirements#
- Host: a systemd-as-PID-1 Linux host (Alma, Debian, Fedora, or similar)
- Container runtime: rootless Podman with lingering enabled
- Tools:
rsync, SSH key access to the host - Linting (dev):
nix(shfmt+shellcheck), or install them directly
Repo layout#
apps/— per-App Quadlet deploy scriptsapps/lib/common.sh— shared deploy/teardown libraryapps/README.md— App conventions contract
servers/— per-host Ansible bootstrap and configure playbooksdocs/adr/— architectural decision recordsCONTEXT.md— ubiquitous-language glossarydocs/agents/— agent operating guide and issue tracker
Getting started#
From a fresh clone the workflow is: copy the server template and bootstrap a
host → for each App, copy its host skeleton and provision its .env → deploy.
Every user-supplied value uses a single placeholder marker, CHANGE_ME (the
server template uses YOUR_SERVER_IP), so one grep -r CHANGE_ME finds every
unfilled value before you deploy.
1. Bootstrap a host#
Copy the server template and fill in your VPS IP:
cp -r servers/alma-linux-9 servers/<my-host>
$EDITOR servers/<my-host>/inventory.yml
inventory.yml has one host entry (key alma-linux-9, ansible_host: YOUR_SERVER_IP). Rename the host key to <my-host> — the SSH alias you'll
pass to deploy-to.sh — and replace YOUR_SERVER_IP with your VPS IP. Then
bootstrap the host:
cd servers/<my-host>
ansible-playbook bootstrap.yml
ansible-playbook configure.yml
2. Deploy an App#
For each App you want to run (e.g. mail, proxy):
-
Copy its host skeleton and fill every
CHANGE_MEplaceholder. Domains in Caddyfiles, the frps dashboard password, etc. all live underhosts/<my-host>/:cp -r apps/<app>/hosts/example apps/<app>/hosts/<my-host> grep -r CHANGE_ME apps/<app>/hosts/<my-host> # fill every hitThe App's own README (
apps/mail/README.md,apps/proxy/README.md) lists the exact placeholders each data file expects. -
Provision
.env(Apps that use one —maildoes;proxydoesn't):cp apps/<app>/.env.example apps/<app>/.env $EDITOR apps/<app>/.env # fill domain placeholdersLeave any
*_SECRETvalue asCHANGE_ME: on first deploy the sync wrapper runsensure_envlocally and replaces it with a random value. Prefer a secret store? Provision.envyour way instead, e.g.skate get … > apps/<app>/.env— the wrapper syncs whatever.envexists and is agnostic to how it got its contents..envis gitignored; never commit it. -
Deploy:
cd apps/<app> ./deploy-to.sh <my-host>deploy-to.shsyncsconfig/, yourhosts/<my-host>/overlay,.env, and the App source to the server, then runsdeploy.shremotely.
3. The CHANGE_ME convention#
CHANGE_ME is the single marker for "you must fill this in". Self-check before
deploying:
grep -r CHANGE_ME servers/<my-host> apps/<app>/hosts/<my-host> apps/<app>/.env
deploy.sh runs check_placeholders post-sync and rejects any deploy where
a CHANGE_ME survives in .env or under config/ on the server, listing
the offending files. A half-filled template fails loudly instead of silently
producing a broken deploy.
Writing a new App#
To add a new App, follow the conventions in apps/README.md.
Further reading#
apps/README.md— App conventions contract (directory structure, service naming, ownership labels, deploy/teardown invariants, dependencies, secrets)CONTEXT.md— ubiquitous language (App, Service, Shared network, Ownership label, etc.)docs/adr/— architectural decisions that ground the project
Linting#
# Format shell scripts (4-space indent, POSIX syntax)
nix run nixpkgs#shfmt -- -i 4 -ln=posix -w \
apps/lib/common.sh apps/proxy/*.sh apps/mail/*.sh apps/mail/scripts/*.sh
# Lint shell scripts
nix run nixpkgs#shellcheck -- \
apps/lib/common.sh apps/proxy/*.sh apps/mail/*.sh apps/mail/scripts/*.sh
License#
MIT — see LICENSE.