- CLI Reference
- Commands
- ironflow deploy
ironflow deploy
Deploy Ironflow to a Kubernetes cluster using Helm deployment templates, or to a single VPS over SSH with ironflow deploy vps.
ironflow deploy [flags]Subcommands:
| Subcommand | Description |
|---|---|
vps | Deploy the whole stack to a single VPS over SSH (no Kubernetes) |
upgrade | Upgrade an existing deployment |
status | Show deployment status |
delete | Delete a deployment |
ironflow deploy (initial deploy)
Section titled “ironflow deploy (initial deploy)”Deploy a new Ironflow instance to Kubernetes.
ironflow deploy [flags]Flags:
| Flag | Short | Type | Default | Description |
|---|---|---|---|---|
--template | string | Deployment template: small, medium, large (required) | ||
--name | string | Helm release name (required) | ||
--namespace | -n | string | ironflow | Kubernetes namespace to deploy into |
--chart | string | Path to Helm chart directory (auto-detected if omitted) | ||
--kubeconfig | string | Path to kubeconfig file (defaults to current kubectl context) | ||
--hetzner-location | string | Hetzner datacenter location (e.g. fsn1, nbg1, hel1). Installs Traefik ingress with Hetzner LB optimizations and applies the full kube-prometheus-stack config (persistent storage, Slack alert routing, Healthchecks.io dead-man’s switch, Grafana admin from external secret). | ||
--set | string[] | Set Helm values (repeatable) |
Examples:
# Small deployment for dev/evaluationironflow deploy --template small --name dev
# Medium deployment for stagingironflow deploy --template medium --name staging
# Large deployment with external dependenciesironflow deploy --template large --name prod \ --set externalDatabase.url=postgres://... \ --set externalNats.url=nats://...
# Deploy to a custom namespaceironflow deploy --template medium --name staging --namespace my-ns
# Deploy with Hetzner load balancerironflow deploy --template medium --name prod --hetzner-location fsn1
# Deploy targeting a specific clusterironflow deploy --template medium --name prod \ --kubeconfig ~/.kube/clusters/hetzner-prod.yamlEnvironment variables: HETZNER_S3_ENDPOINT and HETZNER_S3_BUCKET are auto-injected as the postgresql.objectStore.endpointURL and postgresql.objectStore.destinationPath Helm values (the bucket becomes s3://<bucket>/backups) when they are exported and you have not set the same key via --set. An explicit --set always wins.
Prerequisites: helm and kubectl on your machine, plus a reachable Kubernetes cluster — ironflow deploy, upgrade, status, and delete all shell out to them and fail fast if helm is missing or the API server is unreachable. The cluster-side prerequisites are installed for you on every install and upgrade: cert-manager and kube-prometheus-stack for all templates, plus CloudNativePG and the Barman Cloud plugin for small and medium (large expects an external PostgreSQL). Traefik is the exception — it is installed only when --hetzner-location is passed, so a cluster that needs no external traffic gets no ingress controller and no load balancer.
ironflow deploy vps
Section titled “ironflow deploy vps”Deploy a self-contained Ironflow stack to a single VPS over SSH — no Kubernetes, no Helm. Installs Docker if missing, uploads a generated Docker Compose stack to /opt/ironflow, and starts it.
The stack is Ironflow + PostgreSQL, plus Caddy for automatic HTTPS when --domain is set. NATS runs embedded in the Ironflow process. This is single-node only; use the Helm templates above for clustering.
ironflow deploy vps --host root@1.2.3.4 --domain flow.example.comFlags:
| Flag | Short | Type | Default | Description |
|---|---|---|---|---|
--host | string | SSH target, e.g. root@1.2.3.4 (required) | ||
--domain | string | Domain for automatic HTTPS via Caddy. Its DNS A record must already point at the host (checked, not enforced) | ||
--email | string | Contact email for Let’s Encrypt (used with --domain) | ||
--version | string | latest | Ironflow image tag. Omitted on a re-deploy, the image already on the host is kept | |
--ssh-key | -i | string | SSH private key file (defaults to your ssh-agent / ~/.ssh config) | |
--port | int | 9123 | Host port to publish when --domain is not set |
Examples:
# Production: TLS via Caddy, pinned versionironflow deploy vps --host root@1.2.3.4 --domain flow.example.com \ --email me@example.com --version 0.20.0
# Quick test: plain HTTP on :9123, no domain neededironflow deploy vps --host root@1.2.3.4First boot prints the admin API key and the dashboard admin password. Both are shown once — save them. The command reads them out of the container’s bootstrap key file and deletes it in the same step, so neither credential is left behind in docker compose logs (ADR 0072). Pinning --version to a release older than that change falls back to reading the password out of the log.
With --domain, the command checks the URL it hands you. Before it connects, it resolves the domain and compares the answer against the host, warning when they disagree — including when the domain resolves to your host and to something else, since one extra record is enough to break issuance on its own. Once the stack is up it resolves again — the first answer is minutes old by then, and you may have added the record in between — and fetches https://<domain>/health from your machine, retrying for a few minutes because Let’s Encrypt issuance is lazy and outlives docker compose up.
That fetch is deliberately strict, because the point is to prove the URL serves this deploy. Certificate verification stays on. Redirects are refused, so a parked domain cannot bounce the check to some other origin — http:// included — and have its answer counted. And the response body must be Ironflow’s own health JSON: a bare 200 proves only that something replied, and a hosting default page, a CDN, a status page, or another tenant’s app all reply 200. The Ironflow is up at … line is printed only when all three hold; otherwise you get a diagnostic naming the actual failure — a missing A record, a record pointing elsewhere or proxied through Cloudflare’s orange cloud, a certificate that did not issue, or a reply that came from something other than Ironflow.
Neither check fails the deploy. Split-horizon DNS and proxied records are legitimate, the stack really is running either way, and a certificate can land minutes after any window worth waiting through — Caddy keeps retrying on its own, so fixing the A record needs no re-deploy. The container-level health wait cannot see any of this: it runs wget http://localhost:9123/health inside the Ironflow container, so it never traverses Caddy, never touches port 443, and never resolves the domain.
Re-running the command applies configuration changes in place and keeps the engine already on the host, so re-running to change --port does not upgrade it. Pass --version latest to upgrade, or --version 0.20.0 to move to a specific release. --version still defaults to latest on a first deploy.
Omitting --version on a re-deploy does three things: it reuses the deployed image tag, skips the image pull, and pins the generated docker-compose.yml to the digest of the container actually running — image: ghcr.io/sahina/ironflow-releases:latest@sha256:…. The tag stays readable so you can still see which release the box is on, but the digest is what resolves. That is what keeps a host on the floating latest tag from drifting forward, including under a hand-run docker compose pull && docker compose up on the box itself. The digest is read from the container rather than from the tag, so a pull someone applied but never started does not get deployed by surprise. Passing --version drops the pin and floats the tag again. If the host’s docker-compose.yml is missing or unrecognizable, the command stops rather than guessing — it cannot tell which release is deployed, and falling back to latest would be the upgrade you did not ask for. Re-run with --version to say which release to deploy. If the compose file names one release and the container is running another — an upgrade that uploaded and then failed to start — the container wins and the command says so; the file would otherwise be pinned to the running image while labeled with the release that never started. The reference actually pinned is printed before the stack comes up.
The generated .env on the host holds the Postgres password, IRONFLOW_MASTER_KEY, and POSTGRES_IMAGE. It is written on the first deploy only and never regenerated. Rotating the master key would orphan every encrypted secret, and moving the Postgres image would start a new major against a data volume the old one initialized.
To move an existing deployment to a newer Postgres, dump first, then swap the image and re-initialize the volume. Save this on the host and run it with bash upgrade-pg.sh — do not paste it line by line, because set -e is what stops a failed dump from reaching the irreversible half:
#!/usr/bin/env bashset -euo pipefailcd /opt/ironflow
docker compose stop ironflow # writes after the dump would be lostdocker compose exec -T postgres pg_dump -U ironflow ironflow > ironflow.sql# `set -e` catches a pg_dump that fails outright. The completion trailer is# what proves the file is whole. Everything below this line is irreversible.grep -q 'PostgreSQL database dump complete' ironflow.sql
docker compose downdocker volume rm ironflow_postgres-data
sed -i '/^POSTGRES_IMAGE=/d' .env # older deployments have no such lineecho 'POSTGRES_IMAGE=postgres:17-alpine' >> .env
docker compose up -d --wait postgres # --wait: initdb must finish firstdocker compose exec -T postgres psql -v ON_ERROR_STOP=1 --single-transaction -U ironflow ironflow < ironflow.sqldocker compose up -d.env is the only file that survives a re-run. docker-compose.yml and the Caddyfile are regenerated from your flags every time and overwrite whatever is on the host, so edit the command, not the files — hand edits to /opt/ironflow are lost on the next deploy. Flags you omit revert to their defaults: re-running without --domain removes Caddy and drops TLS. --version is the one exception — omitting it on a re-deploy keeps the engine already on the host, as described above. Before you do upgrade, take a backup: see Backup and restore.
To remove the deployment — this deletes the database, the NATS streams, the TLS certificates, and .env. Once .env is gone, no earlier backup can be decrypted:
ssh root@1.2.3.4 'cd /opt/ironflow && docker compose down -v && rm -rf /opt/ironflow'Prerequisites: ssh and scp on your machine, and a fresh Ubuntu/Debian VPS you can reach as root. The command issues no sudo, so a non-root login (ubuntu@, admin@) fails — on a host that already has Docker it stops before uploading, with advice to reconnect as root; on a fresh host the Docker install step fails first. If you set --domain, create its DNS A record pointing at the VPS before deploying — Caddy’s Let’s Encrypt challenge fails otherwise. The command warns when the record is missing or points elsewhere, but does not stop.
Backup and restore
Section titled “Backup and restore”There is no ironflow backup command for a VPS deployment. The procedure below is the whole of it, and it is manual on purpose — it is also what makes “take a backup before you upgrade” actionable.
A deployment’s state is three things, and a pg_dump alone is none of a working restore:
| What | Where | Holds |
|---|---|---|
| PostgreSQL | ironflow_postgres-data volume | runs, steps, events, functions, API keys |
| NATS JetStream | ironflow_ironflow-data volume | streams, and the SYS_* KV buckets holding every secret and config value |
.env | /opt/ironflow/.env | IRONFLOW_MASTER_KEY, which is what decrypts the secret values in those KV buckets, plus the Postgres password and POSTGRES_IMAGE |
Caddy’s caddy-data and caddy-config volumes are absent from that list on purpose — they need no backup, because a host asks Let’s Encrypt for its own certificate on first boot. The restore below still leaves them alone, so it does not throw away the certificate the fresh deploy has already obtained. (The volume names carry the compose project prefix, so ironflow-data is ironflow_ironflow-data to docker volume. The doubled segment is correct.)
Taking a backup. Save this on the host and run it with bash backup.sh. Only the ironflow container stops, and only for the length of the run: nothing else mounts the ironflow-data volume, so nothing else has to come down for the tar to be consistent. With --domain, Caddy stays up throughout, so the site answers 502 for that window rather than refusing connections. It stops before the dump as well as before the tar, because writes it makes after pg_dump has read would be lost. set -e is what stops a failed dump from being packaged as if it were one.
#!/usr/bin/env bashset -euo pipefailumask 077 # ironflow.sql would otherwise be world-readablecd /opt/ironflow# set -e must not be able to leave the engine stopped: the likeliest failure is# the trailer check below, and a bad dump plus a dead server is the worst of both.trap 'docker compose start ironflow' EXITout=ironflow-backup-$(date +%Y%m%d-%H%M%S)mkdir "$out"
docker compose stop ironflow # sole writer to /data, and to the DBdocker compose exec -T postgres pg_dump -U ironflow ironflow > "$out/ironflow.sql"# `set -e` catches a pg_dump that fails outright. The completion trailer is# what proves the file is whole before it gets packaged as a backup.grep -q 'PostgreSQL database dump complete' "$out/ironflow.sql"
cp .env "$out/env"# The compose file names the exact image the backup was taken from, which is# what a restore has to be pinned to.cp docker-compose.yml "$out/"
docker run --rm -v ironflow_ironflow-data:/data:ro -v "$PWD/$out:/backup" \ alpine tar czf /backup/ironflow-data.tar.gz -C /data .
tar czf "$out.tar.gz" "$out" && rm -rf "$out"chmod 600 "$out.tar.gz"echo "wrote /opt/ironflow/$out.tar.gz"Then pull the archive off the host and delete the copy left behind:
scp root@1.2.3.4:/opt/ironflow/ironflow-backup-*.tar.gz .ssh root@1.2.3.4 'rm -f /opt/ironflow/ironflow-backup-*.tar.gz'Restoring onto a fresh VPS. Deploy first, pinned to the same --version the backup was taken from — restoring into an older engine is the unsupported downgrade described below, and into a newer one it is an upgrade you have not tested. The archive’s own docker-compose.yml carries that reference on its image: line, so you can read it back even when the source host is gone. Then upload the archive:
ironflow deploy vps --host root@5.6.7.8 --domain flow.example.com --version 0.20.0scp ironflow-backup-20260903-120000.tar.gz root@5.6.7.8:/opt/ironflow/That deploy prints its own admin API key and dashboard password. Ignore them — they belong to the database this restore is about to discard. The credentials from the original first boot are the ones that keep working.
Then, on the host, run this with bash restore.sh:
#!/usr/bin/env bashset -euo pipefailcd /opt/ironflowtar xzf ironflow-backup-*.tar.gzsrc=$(ls -d ironflow-backup-*/ | tail -1) # newest, in case an earlier run left one
docker compose down # not `-v`: keep Caddy's certificate volume
# .env goes back BEFORE anything starts again. Boot the stack on the new# deploy's own .env and the server generates a *new* master key: you get a# server that looks healthy and cannot read a single restored secret.cp "$src/env" .envchmod 600 .env
# `find -delete` clears dotfiles too; a bare `rm -rf /data/*` would not.docker run --rm -v ironflow_ironflow-data:/data -v "$PWD/$src:/backup:ro" alpine \ sh -c 'find /data -mindepth 1 -delete && tar xzf /backup/ironflow-data.tar.gz -C /data'
docker volume rm ironflow_postgres-data # discard the database the fresh deploy madedocker compose up -d --wait postgres # --wait: initdb must finish firstdocker compose exec -T postgres psql -v ON_ERROR_STOP=1 --single-transaction \ -U ironflow ironflow < "$src/ironflow.sql"docker compose up -drm -rf "$src" ironflow-backup-*.tar.gz # the archive is a credential; do not leave it hereRestoring .env also puts back POSTGRES_IMAGE, so the fresh initdb runs the same Postgres major the dump came from. That is what you want; a dump does not load into an arbitrary major.
If the restore is onto a new IP, move the domain’s A record to it — Caddy cannot issue a certificate for a name pointing at the old host.
Downgrades are not supported. Running an older --version over a newer one starts old code against an already-migrated database. Take a backup — the full one above, not a bare pg_dump — before you upgrade. A dump on its own restores to a working server with unreadable secrets: IRONFLOW_MASTER_KEY lives in .env, and the KV buckets holding every secret and config value live in the ironflow-data volume, and neither is in the SQL.
ironflow deploy upgrade
Section titled “ironflow deploy upgrade”Upgrade an existing Ironflow deployment.
ironflow deploy upgrade [flags]Flags: Same as ironflow deploy (initial deploy).
Examples:
ironflow deploy upgrade --template medium --name stagingironflow deploy status
Section titled “ironflow deploy status”Show status of an Ironflow deployment.
ironflow deploy status [flags]Flags:
| Flag | Short | Type | Default | Description |
|---|---|---|---|---|
--name | string | Helm release name (required) | ||
--namespace | -n | string | ironflow | Kubernetes namespace |
--kubeconfig | string | Path to kubeconfig file (defaults to current kubectl context) | ||
--watch | -w | bool | false | Watch pod status after showing deployment status |
Examples:
ironflow deploy status --name stagingironflow deploy status --name prod --watchironflow deploy delete
Section titled “ironflow deploy delete”Delete an Ironflow deployment.
ironflow deploy delete [flags]Flags:
| Flag | Short | Type | Default | Description |
|---|---|---|---|---|
--name | string | Helm release name (required) | ||
--namespace | -n | string | ironflow | Kubernetes namespace |
--kubeconfig | string | Path to kubeconfig file (defaults to current kubectl context) |
Examples:
ironflow deploy delete --name staging