package-airflow-red

Creates and operates a single-node Apache Airflow server with Red, OpenTofu and Ansible — Airflow under Docker with LocalExecutor, a host Postgres backed up to R2 with WAL-G, TLS and authentication through Caddy, and DAGs pushed from GitHub Actions over rsync. Use when initializing an airflow project, generating colors.yml, selecting a compute, DNS, SMTP or state provider, building or dry-running configuration, provisioning or destroying the server, or restoring the metadata database.

getcolors/airflow3 installsMITSynced Aug 22

Works with

Claude CodeCursorCodex CLIGitHub CopilotGemini CLI
---
name: package-airflow-red
description: Creates and operates a single-node Apache Airflow server with Red, OpenTofu and Ansible — Airflow under Docker with LocalExecutor, a host Postgres backed up to R2 with WAL-G, TLS and authentication through Caddy, and DAGs pushed from GitHub Actions over rsync. Use when initializing an airflow project, generating colors.yml, selecting a compute, DNS, SMTP or state provider, building or dry-running configuration, provisioning or destroying the server, or restoring the metadata database.
license: MIT
---

# 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 the `red` launcher, in shell history, or in generated
  examples. Every credential arrives through a `COLORS_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_KEY` is 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 `red` launcher or `colors.yml` without
  explicit approval. If a project is already valid, operate it rather than
  regenerating it.
- Default to `build` and `create --dry-run`. Run a real `create` or `delete`
  only after the user confirms that exact operation.
- `build` and `create --dry-run` are credential-free by design and check no
  `COLORS_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-destroy`
  defaults to `true`; authorize an intentional delete with
  `COLORS_PAR_COMPUTE_PREVENT_DESTROY=false` rather than editing desired state.
- `delete` does **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](references/configuration.md) before
generating or changing desired state, and before any real `create` or `delete`.

## Commands

```sh
./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

```text
start ─ compute ─ smtp ─ dns ─ smtp-post ─┬─ ansible-local
                                          ├─ ansible-remote
                                          └─ github
```

1. **compute** provisions the VPS and, on DigitalOcean, a firewall.
2. **smtp** registers `notifications.<zone>` at Resend.
3. **dns** points the host at the machine and publishes the verification records.
4. **smtp-post** verifies the sending domain now that DNS resolves.
5. **ansible-local** writes a `Host <profile>` block into `~/.ssh/config`.
6. **ansible-remote** installs Docker, Postgres, WAL-G, Airflow, Caddy and the
   deploy account.
7. **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:

1. **The web UI is at `https://<airflow-host>`**, behind a browser password
   prompt. The username is `airflow-admin-username`; the password is
   `COLORS_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.
2. **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 = strict` and there is no origin certificate
   yet. That is the expected first-minute state, not a broken deploy.
3. **DAGs are deployed by pushing to the repository**, not by copying files to
   the server. The seeded workflow syncs `dags/` on every push to
   `dags-branch`. Airflow's dag-processor rescans on a timer, so nothing needs
   restarting and the deploy key needs no sudo.
4. **A base backup was taken during the create.** After that they run on
   `walg-full-backup-oncalendar`, and a second timer alerts `alerts-email` if
   the newest one ever ages past `walg-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:

```sh
# 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-version` is pinned. Replace `16` above
  with whatever that key says.
- **`walg-r2-bucket` and the profile must match**, because the archive is stored
  under `s3://<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.

More Data Engineering skills

← All Data Engineering skills

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY