akdarrah
# subscribe

New notes by email, about one a week.

no spam · one-click outor rss · json

adam@akdarrah~/posts$cat four-rails-apps-on-one-server-with-kamal.md

Four Rails apps on one server with Kamal

8 min #rails#tools

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:

config/deploy.yml
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-server

The DNS for every host has to point at the server before the first deploy, or the certificate request fails.

# subscribe

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:

config/deploy.yml
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/data

Kamal 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:

config/deploy.yml
1env:2 clear:3 DB_HOST: blog-db # the blog's Postgres, over the shared "kamal" Docker network

Some details matter here:

  • Bind the port to 127.0.0.1. Docker writes its own firewall rules, so a plain 5432:5432 can 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 setup before 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:

config/deploy.yml
1builder:2 arch: amd643 remote: ssh://root@your-server # build natively on the server

Now 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:

.kamal/secrets
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_KEY

The 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:

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/secrets exists 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 under env.clear in deploy.yml, where I can read them in a diff. One exception: a value I want to change without a commit can live in .env.production too.

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: 30 and deploy_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 sets proxy.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: 2587 fixed 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.server or something else?]
  • Registry push errors can look like success. A push failed with an EOF from localhost: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 the EOF.
  • 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.

share ↓ code.zip
view .md
# subscribe

Liked this? Get the next note by email.

about one a week · no spam · one-click out · or rss

older hnewer lsections j/kyank code ycomments c
↑↓ move↵ open` shellesc close
akdarrah0:posts1:archive2:about3:shell* / search · ` shell
adam@akdarrah~$help
  ls [posts|tags]   list things      cat <slug>   open a post
  grep <word>       search           tree         the site
  subscribe <email>
adam@akdarrah~$tree -L 2 ~
~
├── posts/
│   ├── robot-heads-singing-the-white-lotus-theme.md
│   ├── four-rails-apps-on-one-server-with-kamal.md
│   ├── oh-wait.md
│   └── … 26 more
├── tags/
│   ├── ai  tools  video
│   ├── claude-code  design  process
│   ├── rails  agents  business
│   ├── api  cloudflare  games
│   └── meta  retro
├── about.md
├── archive/
├── feed.xml  feed.json  llms.txt
└── privacy.md  terms.md

4 directories, 49 files