Skip to content

ironflow deploy

Deploy Ironflow to a Kubernetes cluster using Helm deployment templates, or to a single VPS over SSH with ironflow deploy vps.

Terminal window
ironflow deploy [flags]

Subcommands:

SubcommandDescription
vpsDeploy the whole stack to a single VPS over SSH (no Kubernetes)
upgradeUpgrade an existing deployment
statusShow deployment status
deleteDelete a deployment

Deploy a new Ironflow instance to Kubernetes.

Terminal window
ironflow deploy [flags]

Flags:

FlagShortTypeDefaultDescription
--templatestringDeployment template: small, medium, large (required)
--namestringHelm release name (required)
--namespace-nstringironflowKubernetes namespace to deploy into
--chartstringPath to Helm chart directory (auto-detected if omitted)
--kubeconfigstringPath to kubeconfig file (defaults to current kubectl context)
--hetzner-locationstringHetzner 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).
--setstring[]Set Helm values (repeatable)

Examples:

Terminal window
# Small deployment for dev/evaluation
ironflow deploy --template small --name dev
# Medium deployment for staging
ironflow deploy --template medium --name staging
# Large deployment with external dependencies
ironflow deploy --template large --name prod \
--set externalDatabase.url=postgres://... \
--set externalNats.url=nats://...
# Deploy to a custom namespace
ironflow deploy --template medium --name staging --namespace my-ns
# Deploy with Hetzner load balancer
ironflow deploy --template medium --name prod --hetzner-location fsn1
# Deploy targeting a specific cluster
ironflow deploy --template medium --name prod \
--kubeconfig ~/.kube/clusters/hetzner-prod.yaml

Environment 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.

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.

Terminal window
ironflow deploy vps --host root@1.2.3.4 --domain flow.example.com

Flags:

FlagShortTypeDefaultDescription
--hoststringSSH target, e.g. root@1.2.3.4 (required)
--domainstringDomain for automatic HTTPS via Caddy. Its DNS A record must already point at the host (checked, not enforced)
--emailstringContact email for Let’s Encrypt (used with --domain)
--versionstringlatestIronflow image tag. Omitted on a re-deploy, the image already on the host is kept
--ssh-key-istringSSH private key file (defaults to your ssh-agent / ~/.ssh config)
--portint9123Host port to publish when --domain is not set

Examples:

Terminal window
# Production: TLS via Caddy, pinned version
ironflow 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 needed
ironflow deploy vps --host root@1.2.3.4

First 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 bash
set -euo pipefail
cd /opt/ironflow
docker compose stop ironflow # writes after the dump would be lost
docker 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 down
docker volume rm ironflow_postgres-data
sed -i '/^POSTGRES_IMAGE=/d' .env # older deployments have no such line
echo 'POSTGRES_IMAGE=postgres:17-alpine' >> .env
docker compose up -d --wait postgres # --wait: initdb must finish first
docker compose exec -T postgres psql -v ON_ERROR_STOP=1 --single-transaction -U ironflow ironflow < ironflow.sql
docker 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:

Terminal window
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.

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:

WhatWhereHolds
PostgreSQLironflow_postgres-data volumeruns, steps, events, functions, API keys
NATS JetStreamironflow_ironflow-data volumestreams, and the SYS_* KV buckets holding every secret and config value
.env/opt/ironflow/.envIRONFLOW_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 bash
set -euo pipefail
umask 077 # ironflow.sql would otherwise be world-readable
cd /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' EXIT
out=ironflow-backup-$(date +%Y%m%d-%H%M%S)
mkdir "$out"
docker compose stop ironflow # sole writer to /data, and to the DB
docker 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:

Terminal window
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:

Terminal window
ironflow deploy vps --host root@5.6.7.8 --domain flow.example.com --version 0.20.0
scp 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 bash
set -euo pipefail
cd /opt/ironflow
tar xzf ironflow-backup-*.tar.gz
src=$(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" .env
chmod 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 made
docker compose up -d --wait postgres # --wait: initdb must finish first
docker compose exec -T postgres psql -v ON_ERROR_STOP=1 --single-transaction \
-U ironflow ironflow < "$src/ironflow.sql"
docker compose up -d
rm -rf "$src" ironflow-backup-*.tar.gz # the archive is a credential; do not leave it here

Restoring .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.

Upgrade an existing Ironflow deployment.

Terminal window
ironflow deploy upgrade [flags]

Flags: Same as ironflow deploy (initial deploy).

Examples:

Terminal window
ironflow deploy upgrade --template medium --name staging

Show status of an Ironflow deployment.

Terminal window
ironflow deploy status [flags]

Flags:

FlagShortTypeDefaultDescription
--namestringHelm release name (required)
--namespace-nstringironflowKubernetes namespace
--kubeconfigstringPath to kubeconfig file (defaults to current kubectl context)
--watch-wboolfalseWatch pod status after showing deployment status

Examples:

Terminal window
ironflow deploy status --name staging
ironflow deploy status --name prod --watch

Delete an Ironflow deployment.

Terminal window
ironflow deploy delete [flags]

Flags:

FlagShortTypeDefaultDescription
--namestringHelm release name (required)
--namespace-nstringironflowKubernetes namespace
--kubeconfigstringPath to kubeconfig file (defaults to current kubectl context)

Examples:

Terminal window
ironflow deploy delete --name staging