Four Rails apps on one server with Kamal
Yes, Kamal 2 can deploy several Rails apps to one server. Give each app its own config/deploy.yml with a different service name and its own proxy.hosts, point every domain at the same server, and kamal-proxy routes each request by hostname. I run this blog and three other Rails 8 apps that way on one 4 GB droplet.
The routing is the easy part. What took me longer was everything around it: one Postgres for everyone, where the images get built, secrets, and deploying from a git worktree. This is the setup I ended up with, and the things that broke along the way.
When I launched this blog it was one app on a $12 droplet. Now it's four apps on a $24 one.
How does Kamal run several apps on one server?
Kamal 2 replaced Traefik with kamal-proxy. There's one proxy per server, and every app that deploys there registers its hostnames with it. The proxy gets a Let's Encrypt certificate for each host, so ssl: true is all the TLS setup you do.
Each app's config looks like this, and only the names change from app to app:
1service: app-two2image: app-two3 4servers:5 web:6 - your-server7 8proxy:9 ssl: true10 hosts:11 - app-two.example12 - www.app-two.example13 14registry:15 server: localhost:555516 17builder:18 arch: amd6419 remote: ssh://root@your-serverThe DNS for every host has to point at the server before the first deploy, or the certificate request fails.
Get the next note like this by email.
about one a week · no spam · one-click out · or rss
Can several Kamal apps share one Postgres?
Yes, and on a small box you want them to. One Postgres uses far less memory than four. The trick is that only one app owns the database. My blog's deploy.yml declares it as an accessory:
1accessories:2 db:3 image: pgvector/pgvector:pg17-trixie4 cmd: postgres -c max_connections=200 # four apps share this server5 host: your-server6 port: "127.0.0.1:5432:5432"7 env:8 clear:9 POSTGRES_USER: blog10 POSTGRES_DB: blog_production11 secret:12 - POSTGRES_PASSWORD13 directories:14 - data:/var/lib/postgresql/dataKamal names the container <service>-db, so it's blog-db, and it sits on the shared kamal Docker network with every app. The other three apps have no accessories at all. They just point at it:
1env:2 clear:3 DB_HOST: blog-db # the blog's Postgres, over the shared "kamal" Docker networkSome details matter here:
- Bind the port to
127.0.0.1. Docker writes its own firewall rules, so a plain5432:5432can put Postgres on the internet even with a firewall turned on. The apps don't need the port at all, since they connect over the Docker network. I keep it on localhost so I can reach it through an SSH tunnel. - Raise
max_connections. Four apps, each with Puma threads and a Solid Queue worker, each with up to four databases, will use up the default 100. - Deploy the owner first. The app that owns Postgres has to run
kamal setupbefore any other app can boot, and the first deploy also starts kamal-proxy. - Pick the image once. One app needed pgvector, so the shared server runs the pgvector image. I also had to match the Debian release (
trixie) to the one that created the data directory, because a different glibc changes text collation under an existing database.
How do you create the databases for the second app?
A Rails 8 app wants four production databases: primary, cache, queue and cable. Each app gets its own role that owns them, and that role can't create databases, so I create them once by hand with the accessory's superuser:
$ ssh root@your-server # docker exec -i blog-db psql -U blog -d postgres <<'SQL' CREATE ROLE app_two LOGIN PASSWORD '<APP_TWO_DATABASE_PASSWORD>'; CREATE DATABASE app_two_production OWNER app_two; CREATE DATABASE app_two_production_cache OWNER app_two; CREATE DATABASE app_two_production_queue OWNER app_two; CREATE DATABASE app_two_production_cable OWNER app_two; SQL
After that, bin/prod setup (the wrapper is in the secrets section below) boots the app and db:prepare loads the schemas. You don't have to use all four: one of my apps keeps everything in a single database and has only a primary: entry in database.yml.
Do you need Docker Hub or a container registry for Kamal?
No. With registry: server: localhost:5555, Kamal runs a registry for you and reaches it over SSH. I've never made a Docker Hub or GHCR account for any of these apps.
I also don't build images on my Mac anymore. Building amd64 images on Apple Silicon goes through QEMU emulation, and mine hung in apt-get three times in a row. Two lines fixed it:
1builder:2 arch: amd643 remote: ssh://root@your-server # build natively on the serverNow buildx runs on the server itself, and Kamal forwards the registry port to it. [ADAM: confirm how the image travels: built on the server, pushed through the tunnel to the registry on your Mac, then pulled back by the server?]
The cost is that builds use the same CPU and memory as the running apps. On a 4 GB box I haven't had a problem, but I don't run two deploys at once.
How should you handle secrets with Kamal?
Kamal reads secrets from .kamal/secrets, and that file is committed, so it must never hold real values. Mine only names environment variables:
1# Values come from the environment, which bin/prod loads from the2# git-ignored .env.production. Never put real values in this file.3RAILS_MASTER_KEY=$(cat config/master.key)4APP_TWO_DATABASE_PASSWORD=$APP_TWO_DATABASE_PASSWORD5SMTP_PASSWORD=$SMTP_PASSWORD6STRIPE_SECRET_KEY=$STRIPE_SECRET_KEYThe real values live in .env.production, which is git-ignored, and a small wrapper loads it before running Kamal. Every app has the same bin/prod:
1#!/usr/bin/env bash2# Run Kamal with production secrets loaded from .env.production.3# bin/prod setup | deploy | logs | console ...4set -euo pipefail5cd "$(dirname "$0")/.."6[ -f .env.production ] || { echo "Missing .env.production"; exit 1; }7set -a; source .env.production; set +a8exec kamal "$@"So bin/prod deploy, bin/prod logs and bin/prod console (an alias in deploy.yml) are all I type. Two rules have saved me trouble:
- Every name in
.kamal/secretsexists in.env.production, even if it's empty. Kamal expects each one to be set, and my apps treat an empty optional key as "this feature is off" instead of failing. - Only real secrets go under
env.secret. Hosts, ports and the mail sender go underenv.clearindeploy.yml, where I can read them in a diff. One exception: a value I want to change without a commit can live in.env.productiontoo.
Back up .env.production and config/master.key somewhere outside the repo. If you lose the master key, you lose your credentials file with it.
Can you deploy from a git worktree?
Yes, but this is where I've been bitten most. I do a lot of work in git worktrees, and .env.production and config/master.key are both git-ignored, so a new worktree doesn't have them.
A missing file fails loudly: bin/prod stops at the first line, and $(cat config/master.key) errors. A stale file is worse. One worktree had a copy of .env.production from a few days earlier, before I'd added the storage and payment keys. The deploy succeeded, /up was green, and the home page returned 500 because the app couldn't reach its file storage. I redeployed from the main checkout, which had the current file.
Now, before any deploy from a worktree, I copy both files from the main checkout. [ADAM: copy or symlink? A symlink to the main checkout's files would stop them going stale.] And after a deploy I load real pages, not just the health check.
What else goes wrong running several apps on one small server?
- A slow first boot fails the health check. On the shared droplet, one app's first request after boot took about 12 seconds, longer than kamal-proxy's default health check timeout. Its config raises the limits:
proxy.healthcheck.timeout: 30anddeploy_timeout: 120. [ADAM: confirm the 12 s figure.] - Long requests need
response_timeout. One app makes a call to an AI model that can take minutes, so it setsproxy.response_timeout: 600. The default would cut it off. - DigitalOcean blocks outbound SMTP ports. Outbound ports 25, 465 and 587 are blocked. My mail provider (Resend) also listens on 2587, so
SMTP_PORT: 2587fixed it. - Something else can own port 5555. One day a deploy couldn't push to the registry, because the Android emulator on my Mac was already listening on 5555. I moved the registry to 5556 for that deploy. [ADAM: how did you move it, by editing
registry.serveror something else?] - Registry push errors can look like success. A push failed with an
EOFfromlocalhost:5555, but the deploy script I'd run in the background reported exit 0, because the wrapper's exit code was the one I saw. Check Kamal's real exit code, and check a route that only exists in the new code. Retrying once usually fixed theEOF. - Budget memory per app. Each app runs
WEB_CONCURRENCY: 1, runs Solid Queue inside Puma (SOLID_QUEUE_IN_PUMA: true), and limits jobs to one worker process. With four apps and Postgres, the box uses about 2.7 of its 4 GB. [ADAM: still accurate?]
What about backups?
Kamal doesn't back up your accessory. The data directory is just a folder on the server, so a dead droplet takes every app's data with it. I dump all the databases to S3 on a schedule. [ADAM: confirm the nightly dump is running before this goes out; the open thread says it wasn't verified.]
Is one server worth it?
For side projects, yes. Four apps cost me $24 a month, and every deploy is bin/prod deploy from the app's own directory. Each app is its own container, so a bad deploy of one doesn't touch the others, but a full disk or a runaway query on the shared Postgres can. When one of them needs real capacity, it gets its own server: a change to servers: and DB_HOST, plus a database dump and restore.
The Kamal docs cover every key used here. If you want to see how I build the apps before they get here, that's my Claude Code workflow.
Liked this? Get the next note by email.
about one a week · no spam · one-click out · or rss