---
title: "Four Rails apps on one server with Kamal"
url: https://akdarrah.com/posts/four-rails-apps-on-one-server-with-kamal
author: Adam Darrah
published: 2026-11-10T14:47:57Z
updated: 2026-11-10T14:47:57Z
tags: ["rails","tools"]
---

# 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](/posts/now-were-live) 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](https://kamal-deploy.org/docs/configuration/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:

```yaml title=config/deploy.yml
service: app-two
image: app-two

servers:
  web:
    - your-server

proxy:
  ssl: true
  hosts:
    - app-two.example
    - www.app-two.example

registry:
  server: localhost:5555

builder:
  arch: amd64
  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.

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

```yaml title=config/deploy.yml (the blog)
accessories:
  db:
    image: pgvector/pgvector:pg17-trixie
    cmd: postgres -c max_connections=200 # four apps share this server
    host: your-server
    port: "127.0.0.1:5432:5432"
    env:
      clear:
        POSTGRES_USER: blog
        POSTGRES_DB: blog_production
      secret:
        - POSTGRES_PASSWORD
    directories:
      - 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:

```yaml title=config/deploy.yml (every other app)
env:
  clear:
    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:

```terminal
$ 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:

```yaml title=config/deploy.yml
builder:
  arch: amd64
  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:

```sh title=.kamal/secrets
# Values come from the environment, which bin/prod loads from the
# git-ignored .env.production. Never put real values in this file.
RAILS_MASTER_KEY=$(cat config/master.key)
APP_TWO_DATABASE_PASSWORD=$APP_TWO_DATABASE_PASSWORD
SMTP_PASSWORD=$SMTP_PASSWORD
STRIPE_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`:

```bash title=bin/prod
#!/usr/bin/env bash
# Run Kamal with production secrets loaded from .env.production.
#   bin/prod setup | deploy | logs | console ...
set -euo pipefail
cd "$(dirname "$0")/.."
[ -f .env.production ] || { echo "Missing .env.production"; exit 1; }
set -a; source .env.production; set +a
exec 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](https://kamal-deploy.org/docs/configuration/overview/) 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](/posts/my-claude-code-workflow-in-2026).

