Kamal Deployment (Hetzner)
Live. Cutover from Heroku is complete — since 2026-07-23 traffic reaches the Hetzner VM below through the Cloudflare proxy (see
dns.mdand issue #505);kamal-proxyserves it at the origin. See issue #288 for the Heroku migration history.
Architecture
Single Hetzner Cloud VM (CPX32: 4 vCPU / 8GB / 160GB, Ubuntu 24.04), everything as Docker containers managed by Kamal:
webrole: Puma behind Thruster (bin/thrust)workerrole: Sidekiq (bundle exec sidekiq -C config/sidekiq.yml)postgresaccessory: Postgres 17 (matches Heroku’s actualPG Version: 17.9), data bind-mounted to the host so it survives redeploysredisaccessory: Redis 7 (appendonly yes), used for both Sidekiq and the Rails cache store (separate DB indexes — seecaching.md)- Registry: GitHub Container Registry (
ghcr.io/immersive-app/immersive) - TLS: Kamal’s built-in
kamal-proxy, Let’s Encrypt at the origin; Cloudflare proxy in front with SSL mode Full (strict). LE renews via HTTP-01 through the proxy (~30 days before expiry); fallback if that ever fails: a Cloudflare origin certificate on the VM. - Edge: Cloudflare (CDN/WAF, hides the origin IP). Real client IPs are restored in the app
by the
cloudflare-railsgem — required for Rack::Attack per-visitor throttles and Ahoy.
Origin firewall (Cloudflare-only 80/443)
The VM accepts 80/443 only from Cloudflare’s published IP ranges (issue #505), so the
proxy cannot be bypassed by hitting the origin IP. SSH (22) is untouched — Kamal deploys and
bin/deploy are unaffected.
Implementation (on the VM): /usr/local/sbin/cloudflare-origin-firewall.sh, run by
cloudflare-origin-firewall.service at boot (after Docker) and refreshed weekly by the
matching .timer. Rules live in iptables/ip6tables’ DOCKER-USER chain scoped to eth0 —
NOT ufw, because Docker-published ports (kamal-proxy’s 80/443) bypass ufw/INPUT entirely; the
-i eth0 scope keeps container egress (Sidekiq → OpenAI/S3 on 443) untouched. The script is
fail-safe (aborts, leaving rules unchanged, if the Cloudflare range download fails and no
cached copy exists).
# on the VM
systemctl status cloudflare-origin-firewall.service # last apply + rule counts
iptables -L CF-ORIGIN -n | head # the allowed ranges
# emergency rollback (e.g. serving traffic direct during a DNS change):
systemctl stop cloudflare-origin-firewall.service
for ipt in iptables ip6tables; do
$ipt -D DOCKER-USER -i eth0 -p tcp -m multiport --dports 80,443 -j CF-ORIGIN
$ipt -D DOCKER-USER -i eth0 -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
$ipt -F CF-ORIGIN; $ipt -X CF-ORIGIN
done
All roles and accessories currently run on the same VM (see config/deploy.yml) — traffic is
low enough that a single node is sufficient. Splitting roles across multiple servers later only
requires changing the servers: block.
Server naming: immersive-prod-01 (Hetzner Cloud console label, not referenced anywhere in
config/deploy.yml — Kamal only cares about the IP). The trailing number leaves room to expand
without renaming anything already in use — e.g. immersive-prod-02 for a second node, or
immersive-prod-db-01 if Postgres ever moves to its own box.
Initial Setup (one-time)
- Provision the Hetzner VM (
immersive-prod-01) and note its IP (see issue #288 step 1 for firewall/DNS details) - Replace every
<HETZNER_SERVER_IP>placeholder inconfig/deploy.ymlwith that IP - Copy
.env.sampleto.env(gitignored) and fill in real values:KAMAL_REGISTRY_PASSWORD— a GitHub PAT withwrite:packagesscopePOSTGRES_PASSWORD— a new random value, only used for the container’s internal authRAILS_MASTER_KEYis not stored in.env— see Deploying below
- Load the env and run first-time setup:
set -a; source .env; set +a RAILS_MASTER_KEY=<value> bin/kamal setup
Deploying
bin/deploy # build, push, deploy web + worker
Always use bin/deploy, not bare bin/kamal deploy. The wrapper resolves the secrets
Kamal needs and then execs bin/kamal deploy (extra arguments pass through):
KAMAL_REGISTRY_PASSWORD/POSTGRES_PASSWORDare sourced from.env(gitignored; copy.env.sample). A barebin/kamal deployin a shell that hasn’t sourced.envfails at the GHCR login withflag needs an argument: 'p'.RAILS_MASTER_KEYis deliberately never stored in the repo,.env, orconfig/master.key— a stray copy breaks local dev and test (see.env.sampleand.kamal/secretsfor the failure modes).bin/deployfetches it over SSH from the live production web container’s environment, the source of truth since Heroku was decommissioned. A backup copy lives in Apple Passwords undersean@immersive-app.com.
Tag-based deploy from GitHub (#400)
Pushing a v* tag runs .github/workflows/deploy.yml, which performs the same
bin/kamal deploy on a GitHub Actions runner. Merged PRs therefore do not ship until a
release is cut: GitHub → Releases → “Create a release” (also available in the GitHub
mobile app) creates the tag and triggers the deploy. The workflow reads the four
secrets from repo Settings → Secrets and variables → Actions (KAMAL_REGISTRY_PASSWORD,
RAILS_MASTER_KEY, POSTGRES_PASSWORD, SSH_PRIVATE_KEY — the private half of a
dedicated keypair whose public half is in /root/.ssh/authorized_keys on the Hetzner
host). It can also be run manually against main from the Actions tab
(workflow_dispatch), without cutting a tag. A failed deploy leaves the previous
containers running (Kamal only stops the old version after the new one is healthy);
fix and cut a new tag (or delete + recreate the tag), or re-run from the Actions tab.
After the smoke check the workflow regenerates the sitemap (#592): the file lives on
the web container’s filesystem, so each deploy wipes it, and the workflow’s final step
runs sitemap:refresh in the fresh container and verifies the URL serves. The verify
uses a cache-busting query because Cloudflare caches a 404 for .gz paths for up to
4 hours; if the canonical URL 404s right after a deploy, that cache is why — purge the
single URL in Cloudflare (Caching → Purge → Custom purge) or wait it out.
Other Kamal Commands
bin/kamal app logs # tail logs
bin/kamal app details # current container status
bin/kamal app boot # restart in place
bin/kamal rollback # revert to the previous image
bin/kamal app exec --reuse "bin/rails console"
Accessories
bin/kamal accessory boot postgres
bin/kamal accessory boot redis
bin/kamal accessory logs postgres
bin/kamal accessory exec postgres "psql -U immersive -d immersive_production"
Database Management
lib/tasks/backup.rake is unchanged from the Heroku setup — it dumps Postgres and uploads to
S3 with retention cleanup regardless of where Postgres is running.
# Manual backup
bin/kamal app exec --reuse "bin/rails db:backup"
# List backups in S3
bin/kamal app exec --reuse "bin/rails db:list_backups"
In production this runs nightly via a root crontab entry on the VM (see issue #288 step 5), replacing Heroku Scheduler, with a Sentry cron check-in (see Monitoring below) so a silently-broken cron gets caught.
Environment Variables Required
| Variable | Purpose |
|---|---|
RAILS_MASTER_KEY |
Rails credentials decryption key |
DATABASE_HOST / DATABASE_PORT / DATABASE_NAME / DATABASE_USER |
Built into the Postgres connection URL in config/database.yml |
POSTGRES_PASSWORD |
Postgres accessory auth |
REDIS_URL_HOST / REDIS_URL_PORT |
Used by Sidekiq (config/initializers/sidekiq.rb) and the cache store (config/environments/production.rb) |
HOST |
immersive-app.com — used in mailer/controller default URL options |
KAMAL_REGISTRY_PASSWORD |
GHCR auth (deploy-time only, not injected into the app container) |
The DATABASE_*/REDIS_URL_* clear values are set in config/deploy.yml’s env.clear block;
RAILS_MASTER_KEY and POSTGRES_PASSWORD are env.secret entries resolved from .kamal/secrets
at deploy time.
Monitoring (Sentry)
Account: signed up via GitHub SSO (linked to the scarroll32 GitHub account, free Developer
plan, no card required). Switched to Sentry from an initial Better Stack signup — see
issue #288 Decisions: Sentry’s free tier
bundles exactly what’s needed (1 uptime monitor, 1 cron monitor, 5,000 errors/mo) in one tool,
rather than splitting uptime/heartbeat/error-tracking across separate services.
- Uptime monitor:
https://immersive-app.com/up(set up in the Sentry dashboard) - Cron/heartbeat monitor: wired directly into
lib/tasks/backup.rakevia the SDK’s check-in API — no separate dashboard step, the monitor registers itself on first run - Error tracking:
sentry-ruby+sentry-railsgems, DSN in Rails encrypted credentials (Rails.application.credentials.dig(:sentry, :dsn)), no performance tracing enabled (keeps event usage to just exceptions, comfortably inside the free tier budget)
Failure Detection & Recovery
See issue #288 for the full runbook. Short version:
- App down: Sentry uptime alert →
bin/kamal app logs→bin/kamal rollbackorbin/kamal app boot - Backup cron silently stopped: Sentry cron monitor alert (missed check-in) → SSH in, check
crontab -land container logs → re-run the backup task manually - VM lost: boot from the latest Hetzner Snapshot,
bin/kamal deploy, re-point DNS, restore the latest S3 backup into the Postgres accessory (see issue #288 runbook for full detail)