npx skills add https://github.com/getcolors/airflow --skill package-airflow-redSKILL.md
An Apache Airflow server, with Colors
Use this skill to initialize or operate an airflow project in the user's current directory. It provisions one VPS running Airflow, a host Postgres archiving continuously to object storage, and a Caddy that terminates TLS and holds the only login. DAGs are pushed to it from a GitHub Actions workflow.
Requirements
Bun runs the launcher. create and delete also need OpenTofu, Ansible
and the gh CLI. Provider credentials use COLORS_PAR_* variables, except OCI,
which uses the profile named in ~/.oci/config, and S3, which uses OpenTofu's
ambient AWS credential chain.
Non-negotiable safety rules
- Never ask the user to paste a secret into chat.
- Never put API tokens, passwords, private keys, Fernet keys or access keys in
colors.yml, in theredlauncher, in shell history, or in generated examples. Every credential arrives through aCOLORS_PAR_*environment variable named after the key it fills. Suggest a gitignored.envrc.private, never an inline export a shell history records. - Never set
COLORS_PAR_PROFILE. The package refuses to run when it is set, and suggesting it as a workaround defeats the guard. It matters more here than in the other packages in this stack: three of the four OpenTofu stages carry ONCE's stage names, so the profile is the only thing separating this project's state from another Colors project's in the same bucket. COLORS_PAR_AIRFLOW_FERNET_KEYis part of the backup. It encrypts every stored Airflow connection. Restoring a database with a different one leaves those connections undecryptable — a broken restore that looks like a successful one until a DAG uses a connection. Never regenerate it for an existing deployment, and make sure the user has it stored somewhere that survives the machine.- Do not overwrite an existing
redlauncher orcolors.ymlwithout explicit approval. If a project is already valid, operate it rather than regenerating it. - Default to
buildandcreate --dry-run. Run a realcreateordeleteonly after the user confirms that exact operation. buildandcreate --dry-runare credential-free by design and check noCOLORS_PAR_*at all. A clean dry-run says nothing about whether real provisioning would authenticate; never report it as credential validation.- Before
delete, say plainly that the metadata database goes with the droplet. Every DAG run history, connection, variable and XCom lives on the boot volume. It is recoverable from the WAL-G archive by the documented procedure below, and that procedure is manual.compute-prevent-destroydefaults totrue; authorize an intentional delete withCOLORS_PAR_COMPUTE_PREVENT_DESTROY=falserather than editing desired state. deletedoes not delete the DAG repository, and must not be described as if it might. It revokes the deploy key and clears the Actions environment.- Never edit anything under
.colors/— it is generated output.
Read references/configuration.md before
generating or changing desired state, and before any real create or delete.
Commands
./red build # render .colors/<profile>/ only; contacts nothing
./red create --dry-run # print the graph; touches nothing
./red create # provision, configure, and publish the deploy key
./red delete # revoke the key, then destroy — the repo is kept
-f/--file overrides the colors.yml found by walking up from the working
directory.
There is no stop, no start and no describe. The power verbs are walter's
and are implemented for OCI only; an Airflow scheduler runs continuously anyway,
so a box that cannot be parked costs nothing that was not already being paid.
What a create does, in order
start ─ compute ─ smtp ─ dns ─ smtp-post ─┬─ ansible-local
├─ ansible-remote
└─ github
- compute provisions the VPS and, on DigitalOcean, a firewall.
- smtp registers
notifications.<zone>at Resend. - dns points the host at the machine and publishes the verification records.
- smtp-post verifies the sending domain now that DNS resolves.
- ansible-local writes a
Host <profile>block into~/.ssh/config. - ansible-remote installs Docker, Postgres, WAL-G, Airflow, Caddy and the deploy account.
- github creates the DAG repository if it is missing, publishes the deploy key to an Actions environment named after the profile, and — only for a repository this run created — seeds a workflow and a hello-world DAG.
The SMTP ordering is why steps 2–4 cannot be collapsed: the sending domain must exist before its verification records can be rendered into DNS, and DNS must be live before verification runs.
On delete the graph reverses and github runs first, so credentials are
withdrawn before anything is destroyed.
After the first create
Tell the user these four things, because none of them are obvious:
- The web UI is at
https://<airflow-host>, behind a browser password prompt. The username isairflow-admin-username; the password isCOLORS_PAR_AIRFLOW_ADMIN_PASSWORD. That prompt is Caddy's, not Airflow's — there is one login for one operator and no user model behind it. - The certificate takes a minute. Caddy answers an ACME HTTP-01 challenge
on port 80 the first time. Until it completes, Cloudflare returns 526,
because the zone is set to
ssl = strictand there is no origin certificate yet. That is the expected first-minute state, not a broken deploy. - DAGs are deployed by pushing to the repository, not by copying files to
the server. The seeded workflow syncs
dags/on every push todags-branch. Airflow's dag-processor rescans on a timer, so nothing needs restarting and the deploy key needs no sudo. - A base backup was taken during the create. After that they run on
walg-full-backup-oncalendar, and a second timer alertsalerts-emailif the newest one ever ages pastwalg-max-backup-age-hours.
Restoring the metadata database
There is deliberately no restore verb. A command whose purpose is
overwriting a live database, one typo away from create in the same CLI, is a
hazard that outweighs the convenience. Restoring is manual, and it goes:
# On the machine, as root.
systemctl stop docker # nothing may write while this runs
systemctl stop postgresql@16-main
rm -rf /var/lib/postgresql/16/main # WAL-G restores into an empty dir
sudo -u postgres /usr/local/bin/wal-g-wrapper backup-fetch \
/var/lib/postgresql/16/main LATEST
# Tell Postgres to replay the archive, then let it start.
sudo -u postgres tee /var/lib/postgresql/16/main/recovery.signal </dev/null
sudo -u postgres tee -a /etc/postgresql/16/main/conf.d/airflow.conf <<'EOF'
restore_command = '/usr/local/bin/wal-g-wrapper wal-fetch %f %p'
EOF
systemctl start postgresql@16-main # watch the log until recovery ends
systemctl start docker
Before recommending it, check three things with the user:
- The Fernet key must be the one the backup was taken with. See the safety rules above. This is the failure that looks like a success.
- The Postgres major version must match. WAL-G backups do not restore across
major versions, which is why
postgres-versionis pinned. Replace16above with whatever that key says. walg-r2-bucketand the profile must match, because the archive is stored unders3://<bucket>/<profile>. Restoring into a project with a different profile finds nothing.
Reporting
Say what actually happened. If Ansible failed at a task, name the task. If a
gh call failed, say which credential it was publishing. Never report a
successful build as evidence that a create would work — it renders from
desired state alone and contacts nothing.