feat(coolify): install the control-plane dump as a systemd timer #9

Merged
dan-claude-bot merged 1 commit from feat/coolify-backup-timer into main 2026-07-12 19:16:53 +00:00
dan-claude-bot commented 2026-07-12 19:14:29 +00:00 (Migrated from github.com)

Closes #8.

rig coolify install used to end by handing the nightly control-plane dump to a human
runbook. Some of what it hands off genuinely is runbook work — an admin user and an API
token are console acts against a running Coolify. The dump isn't: it runs on the box, as
root, under a scheduler
, which is rig's job description verbatim.

What lands

rig coolify backup install [--schedule <OnCalendar>] [--pg-container <name>]
                           [--pg-user <name>] [--pg-db <name>]

Installs age + awscli, /usr/local/sbin/coolify-dump.sh, a systemd service + timer, and
an empty 0600 bindings file at /etc/coolify-dump.env. Convergent — a bindings file
that already exists is never touched.

The dump script is re-homed out of cast, where it was a stowaway: cast is the off-box
tool and its src/ never references the script. Its first line is docker exec coolify-db pg_dump …, which only runs where the Coolify containers are.

The seam: rig installs machinery, never credentials

Per the README's own philosophy — "stores no credential, ever":

rig installs you supply
age + awscli AGE_RECIPIENT (a public key)
the dump script S3_BUCKET, S3_ENDPOINT
the service + timer S3 access key / secret / region
the empty 0600 bindings file by filling it, and nothing else

rig writes that file blank and never reads it back. The script's guards
(: "${AGE_RECIPIENT:?…}") make an unfilled file fail the unit loudly rather than ship
plaintext or a zero-byte artifact. A silent backup is worse than a missing one.

Why a timer, not cron

EnvironmentFile= is the correct idiom for 0600 secrets (no set -a sourcing wrapper),
failures surface in systemctl status / list-timers rather than being mailed into a void
with no MTA, and Persistent=true catches a run missed while the box was down. Cron gives
none of that and needs a wrapper script just to load the env.

Two hazards the cast script missed

  • aws-cli >= 2.23 breaks against S3-compatible backends. 2.23 turned on new default
    upload checksums that Hetzner/MinIO/Ceph reject — and Debian 13 ships awscli 2.23.6,
    so this is the default install, not an edge case. The unit defaults both checksum knobs to
    when_required.
  • A failed pg_dump piped into age still produces a valid, tiny, encrypted file. It
    would upload cleanly every night and look exactly like a working backup. The script now
    refuses to upload an empty artifact.

Why this is a correctness fix, not tidiness

The control-plane dump is explicitly forensics, not a restore path: a lost control plane is
rebuilt fresh and reconciled from its manifest. So there will be a next control-plane
box — that is the design. As a runbook step, every rebuilt control plane was born
un-backed-up, depending on someone remembering it mid-incident. Now it's backed up from
birth, and rig coolify install points at it as the next step.

Testing

bash test/cli.sh35 passed, 0 failed (27 existing + 8 new: dispatch, bad subcommand,
--help, flag-needs-value ×2, unknown flag, non-root refusal). shellcheck clean. The
generated systemd unit was rendered and inspected.

Not covered, deliberately: rig cannot verify an upload actually works — that needs real
credentials. The command prints the manual gate (systemctl start coolify-dump.service, then
confirm the object is in the bucket and is really age ciphertext). Reading a backup back
should stay the operator's job.

🤖 Generated with Claude Code

Closes #8. `rig coolify install` used to end by handing the nightly control-plane dump to a human runbook. Some of what it hands off genuinely is runbook work — an admin user and an API token are console acts against a running Coolify. The dump isn't: it runs **on the box, as root, under a scheduler**, which is rig's job description verbatim. ## What lands ```sh rig coolify backup install [--schedule <OnCalendar>] [--pg-container <name>] [--pg-user <name>] [--pg-db <name>] ``` Installs `age` + `awscli`, `/usr/local/sbin/coolify-dump.sh`, a systemd service + timer, and an **empty `0600`** bindings file at `/etc/coolify-dump.env`. Convergent — a bindings file that already exists is never touched. The dump script is **re-homed out of `cast`**, where it was a stowaway: `cast` is the off-box tool and its `src/` never references the script. Its first line is `docker exec coolify-db pg_dump …`, which only runs where the Coolify containers are. ## The seam: rig installs machinery, never credentials Per the README's own philosophy — *"stores no credential, ever"*: | rig installs | you supply | | --- | --- | | `age` + `awscli` | `AGE_RECIPIENT` (a **public** key) | | the dump script | `S3_BUCKET`, `S3_ENDPOINT` | | the service + timer | S3 access key / secret / region | | the **empty** `0600` bindings file | by filling it, and nothing else | rig writes that file blank and never reads it back. The script's guards (`: "${AGE_RECIPIENT:?…}"`) make an unfilled file **fail the unit loudly** rather than ship plaintext or a zero-byte artifact. A silent backup is worse than a missing one. ## Why a timer, not cron `EnvironmentFile=` is the correct idiom for `0600` secrets (no `set -a` sourcing wrapper), failures surface in `systemctl status` / `list-timers` rather than being mailed into a void with no MTA, and `Persistent=true` catches a run missed while the box was down. Cron gives none of that and needs a wrapper script just to load the env. ## Two hazards the `cast` script missed - **`aws-cli >= 2.23` breaks against S3-compatible backends.** 2.23 turned on new default upload checksums that Hetzner/MinIO/Ceph reject — and **Debian 13 ships `awscli` 2.23.6**, so this is the default install, not an edge case. The unit defaults both checksum knobs to `when_required`. - **A failed `pg_dump` piped into `age` still produces a valid, tiny, encrypted file.** It would upload cleanly every night and look exactly like a working backup. The script now refuses to upload an empty artifact. ## Why this is a correctness fix, not tidiness The control-plane dump is explicitly *forensics, not a restore path*: a lost control plane is **rebuilt fresh** and reconciled from its manifest. So there **will** be a next control-plane box — that is the design. As a runbook step, every rebuilt control plane was born un-backed-up, depending on someone remembering it mid-incident. Now it's backed up from birth, and `rig coolify install` points at it as the next step. ## Testing `bash test/cli.sh` → **35 passed, 0 failed** (27 existing + 8 new: dispatch, bad subcommand, `--help`, flag-needs-value ×2, unknown flag, non-root refusal). shellcheck clean. The generated systemd unit was rendered and inspected. **Not covered, deliberately:** rig cannot verify an upload actually works — that needs real credentials. The command prints the manual gate (`systemctl start coolify-dump.service`, then confirm the object is in the bucket and is really `age` ciphertext). Reading a backup back should stay the operator's job. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/rig#9
No description provided.