Skip to content

guide

Deploy with Kamal 2 to a VPS

Kamal ships Docker containers to plain Linux servers over SSH. One YAML file, one command, and kamal-proxy swaps old containers for new ones without dropping traffic.

Last updated

Who is Kamal for?

Kamal suits teams who already have a Dockerfile and want push-button deploys to one or a few servers without running Kubernetes. It is a good match for Rails, Django, Node and Go web apps. It is a poor match if you need autoscaling, a scheduler across dozens of hosts, or you have no wish to touch containers at all.

Kamal runs on your machine or in CI, not on the server. It reads config/deploy.yml, connects to each host over SSH and drives Docker there. Nothing stays resident on the VPS except Docker, your containers and kamal-proxy.

What do you need before you start?

You need a VPS with a public IPv4 address, SSH key access, a domain whose A record points at that address, a container registry account and a working Dockerfile. Locally you need Ruby 3.x and Docker with buildx. Open ports 22, 80 and 443 in the portal firewall before running anything.

  • A server: a Boost (2 vCPU, 4 GB) is comfortable for an app plus Postgres. Spark works for a single small service.
  • DNS: app.example.com A 203.0.113.10. No AAAA record, since there is no IPv6 on FastNode.
  • Your app must answer 200 on /up, the default health check path, or you set a different one.

Which SSH user should Kamal connect as?

Kamal connects as root unless you set ssh.user. Root is simplest because kamal setup can then install Docker. A dedicated user is tidier, but Docker must already be installed and that user must be in the docker group, which is effectively root-equivalent anyway. Either way, use key authentication only.

# on the server, if you choose a deploy user (Ubuntu 24.04)
sudo adduser --disabled-password --gecos '' deploy
sudo usermod -aG docker deploy
sudo install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
sudo cp ~/.ssh/authorized_keys /home/deploy/.ssh/ && sudo chown deploy: /home/deploy/.ssh/authorized_keys

Disable password logins before the first deploy; the SSH hardening guide walks through it. If you need Docker on the host first, follow installing Docker on Ubuntu or Debian.

Install Kamal and generate the config

gem install kamal
cd ~/code/myapp
kamal init

kamal init writes three things: config/deploy.yml, .kamal/secrets and a .kamal/hooks/ folder of sample hook scripts. Rails 8 apps ship with these already.

A working config/deploy.yml

service: myapp
image: example/myapp

servers:
  web:
    - 203.0.113.10

proxy:
  ssl: true
  host: app.example.com
  app_port: 3000
  healthcheck:
    path: /up

registry:
  server: ghcr.io
  username: example
  password:
    - KAMAL_REGISTRY_PASSWORD

builder:
  arch: amd64

ssh:
  user: deploy

env:
  clear:
    NODE_ENV: production
    DB_HOST: myapp-db
  secret:
    - DATABASE_PASSWORD
    - SECRET_KEY_BASE

accessories:
  db:
    image: postgres:16
    host: 203.0.113.10
    port: "127.0.0.1:5432:5432"
    env:
      clear:
        POSTGRES_USER: myapp
      secret:
        - POSTGRES_PASSWORD
    directories:
      - data:/var/lib/postgresql/data
  redis:
    image: redis:7
    host: 203.0.113.10
    port: "127.0.0.1:6379:6379"
    directories:
      - data:/data

Points worth checking line by line:

  • app_port is the port your app listens on inside the container. kamal-proxy defaults to 80, so set it if you use 3000 or 8080.
  • builder.arch: amd64 matters if you build on an ARM laptop; FastNode instances are x86-64.
  • Accessories bind to 127.0.0.1. Docker-published ports skip ufw rules, so a bare "5432:5432" would expose Postgres to the internet.
  • Accessory containers are named service-accessory, so the app reaches Postgres at myapp-db on Kamal's Docker network.

Where do secrets go?

Secrets live in .kamal/secrets, a dotenv file Kamal reads at deploy time and injects into containers as environment variables. Keep raw values out of it: reference variables from your shell or CI, or call a password manager. Add the file to .gitignore if it holds anything literal.

# .kamal/secrets
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
DATABASE_PASSWORD=$DATABASE_PASSWORD
POSTGRES_PASSWORD=$DATABASE_PASSWORD
SECRET_KEY_BASE=$SECRET_KEY_BASE

Only names listed under env.secret reach the container. A variable sitting in the secrets file but not listed is ignored.

First deploy, then every deploy after

kamal setup      # first time: Docker (if root), proxy, accessories, app
kamal deploy     # every release after that

A deploy builds and pushes the image tagged with the git commit, pulls it on the server, starts the new container, waits for /up to pass, tells kamal-proxy to route to it, then stops the old one. If the health check never passes, the old container keeps serving and the deploy fails loudly.

How does kamal-proxy get the certificate?

With ssl: true and a host set, kamal-proxy requests a Let's Encrypt certificate on the first HTTPS request and renews it on its own. The challenge runs on port 443, so that port must be reachable and the A record must already resolve to your server. This automatic mode suits a single server per hostname.

Check it with:

dig +short app.example.com A     # should print 203.0.113.10
kamal proxy logs
curl -I https://app.example.com/up

Let's Encrypt only works when one server answers for the name. With several web servers, load your own certificate through ssl.certificate_pem and ssl.private_key_pem secrets, or terminate TLS in front and turn ssl off.

Logs, consoles and rollback

kamal app logs -f               # follow app output
kamal app details               # running containers and versions
kamal app exec -i 'bin/rails console'
kamal accessory logs db
kamal rollback 4f1c2ab          # earlier commit tag still on the server

Rollback restarts an older image that is still present on the host, so it takes seconds. Kamal prunes old images after deploys and keeps only recent ones, so a release from months ago may need redeploying from git instead. Schema changes need care: roll the code back only if the previous release tolerates the current database.

What doesn't Kamal do for you?

Kamal deploys containers and routes traffic. It does not harden SSH, configure a firewall, apply OS security updates, back up accessory volumes or monitor the host. Those jobs stay with you, and leaving them undone is the usual way a tidy Kamal setup turns into an incident.

  • Updates: enable unattended-upgrades on Ubuntu 24.04 and reboot on a schedule.
  • Firewall: allow 22 from your addresses, 80 and 443 from anywhere, in the portal per-VM firewall.
  • Database backups: dump on a timer, for example docker exec myapp-db pg_dump -U myapp myapp | gzip > /var/backups/myapp-$(date +%F).sql.gz, and copy the files off the server. If backups are enabled on your server, check the portal for schedule and retention; take a snapshot before risky migrations. See backups and snapshots.
  • Monitoring: Kamal will not tell you the disk is full. Old images and Postgres data fill a 20 GB Spark faster than expected; watch df -h.

If you would rather run everything from one Compose file on the server, compare with Docker Compose in production.

Questions

Does Kamal need anything installed on the server first?

Only SSH access. If you connect as root, kamal setup installs Docker for you. If you connect as a normal user, install Docker yourself and add that user to the docker group, because Kamal will not escalate with sudo.

Can I deploy without a container registry?

Yes. Set registry.server to a localhost address such as localhost:5555 and Kamal runs a local registry, pushing the image to your servers over SSH. Otherwise Docker Hub, GitHub Container Registry or your own registry work; use a private repository and a push-and-pull token.

Why did the Let's Encrypt certificate fail to issue?

Almost always DNS or ports. The A record must already point at the VPS IPv4, and ports 80 and 443 must be open in the portal firewall. Do not add an AAAA record: FastNode instances have IPv4 only.

Is Kamal a good fit for a Spark plan?

For one small app, yes. Image pulls and two containers running briefly during a switch need headroom, so leave at least 500 MB of RAM spare. Build images in CI or on your laptop, never on a 2 GB server.