Kamal + Docker: Deploying Prebuilt Images to Production

Kamal isn't just for apps you build — here's the pattern for deploying prebuilt third-party Docker images to your own server.

For a long time, I assumed Kamal was only for applications you build yourself: push your repo, and Kamal builds the image and ships it to your server. That’s the path the documentation walks you through, and it’s the workflow most examples online demonstrate.

When a client needed to self-host a third-party app with a publicly-released Docker image, my first instinct was to reach for something else — a PaaS, a Compose file, a different tool, or attaching it as a Kamal accessory to a Rails app. Then I tried Kamal anyway, and it just worked. As long as a valid Docker image exists in a registry your server can reach, Kamal will pull it, run it, manage the proxy in front of it, restart it, and roll back if something breaks. The build step is optional.

Since that discovery, deploying prebuilt third-party images with Kamal has become my default for self-hosted client work — apps like Campfire, Plausible CE, and others, each running as a standalone service rather than as an accessory to a Rails application. This post walks through the pattern using Basecamp’s Campfire as the example.

How Kamal Handles Images Without Building

A standard Kamal flow has three phases: build the image, push it to a registry, and run it on the server. When you deploy a prebuilt image, you skip the first two phases entirely. The image is already published — to Docker Hub, GHCR, or any other OCI registry — and your server only needs to pull it.

Your deploy.yml reflects this. The image: field points to the published image; your registry: section provides credentials for the registry hosting it, and you never run kamal build. Everything else — proxy configuration, environment variables, volumes, accessories, healthchecks — works exactly as it would for an app you built yourself.

Deploying Campfire in One Configuration File

Campfire publishes a public image at basecamp/once-campfire on Docker Hub. Here is a minimal deploy.yml to run it:

service: campfire

image: basecamp/once-campfire

ssh:
  user: kamal
  port: 22

servers:
  web:
    hosts:
      - 192.168.1.130
    options:
      volume:
        - campfire-data:/rails/storage
    proxy:
      host: campfire.example.com
      app_port: 3000
      ssl: true
      healthcheck:
        path: /up
        interval: 5
        timeout: 60

registry:
  server: docker.io
  username:
    - KAMAL_REGISTRY_USERNAME
  password:
    - KAMAL_REGISTRY_PASSWORD

builder:
  arch: amd64

env:
  secret:
    - SECRET_KEY_BASE

A few things are worth pointing out. The volume: line keeps Campfire’s SQLite database and uploads on the host between deploys — without it, every restart loses your data. The proxy: block tells the Kamal proxy how to route traffic to the container, terminate SSL, and run healthchecks against /up, the default Rails health endpoint that Campfire ships with. The builder.arch: amd64 line looks redundant when you are not building, but Kamal still uses it to know which architecture variant to pull — more on that below.

Provide your secrets in .kamal/secrets:

KAMAL_REGISTRY_USERNAME=your-dockerhub-user
KAMAL_REGISTRY_PASSWORD=your-dockerhub-token
SECRET_KEY_BASE=...

The First Deploy and Subsequent Updates

The command sequence is identical to a normal Kamal deploy. The first time:

kamal setup

This installs Docker on the server, logs into the registry, sets up the Kamal proxy, pulls the image, and starts the container. From here on, every update is:

kamal deploy

kamal setup is the one-time bootstrap — it prepares the host, installs the proxy, and authorizes the registry. kamal deploy is the repeatable command: it pulls the image, starts a new container, waits for healthchecks, and swaps traffic. If the new container fails its healthcheck, the old one keeps serving requests.

To roll out a new version of Campfire when one is released, bump the image tag in deploy.yml and run kamal deploy again.

Commands worth knowing

Command What it does
kamal setup One-time host preparation: Docker, registry login, proxy install
kamal deploy Pull the image, start a new container, healthcheck, swap traffic
kamal rollback Switch traffic back to the previous container
kamal logs Tail the logs of the running container
kamal console Open a console inside the running container
kamal restart Restart the container without changing the image

The Kamal proxy

Kamal does not expose your container directly to the internet. A proxy runs in front of it, and that is where TLS termination, host routing, and the healthcheck loop live — which is why proxy: needs an app_port (the port inside the container) and an ssl: true flag for a public host.

The proxy also handles zero-downtime rollouts. When you deploy, Kamal starts the new container, waits for its healthcheck to pass, and only then shifts traffic. A failing container never receives a single request, and kamal rollback reverses the swap in seconds.

Things to Watch Out For

A few details that surprised me when I started doing this:

Pin image tags. Do not deploy image: basecamp/once-campfire and let Docker resolve :latest — pin a specific tag like basecamp/once-campfire:1.2.0 or whichever release you have tested. Without a pin, two kamal deploy runs days apart can ship different versions, and rollbacks become unreliable.

Architecture matters even without building. If your laptop is an Apple Silicon Mac and your server is x86_64, the builder.arch: amd64 line tells Kamal which architecture variant of the multi-arch image to pull. Without it, you may get an ARM image that won’t run on your server, or fall back to emulation that silently degrades performance.

Healthcheck paths are not universal. Rails apps usually expose /up, but other prebuilt images don’t. Plausible CE responds at /api/health, for instance. Check the app’s documentation and adjust proxy.healthcheck.path accordingly, or deploys will hang waiting for a check that never passes.

Use accessories for sidecars, not the main container. Do not try to layer in a database, a backup process, or a worker by modifying the third-party image. Kamal’s accessories: block is built for exactly this. I run a Litestream accessory on my Fizzy deployment to back up SQLite — separate process, shared volume, zero modifications to the upstream image. It’s the same pattern I reached for after upgrading an old Rails app to 8.1 and moving it to SQLite, once it was ready to deploy.

An accessory is a small service with its own image, its own command, and its own volume bindings, defined in the same deploy.yml:

accessories:
  litestream:
    image: litestream/litestream
    host: 192.168.1.130
    command: replicate --endpoint "s3://my-bucket" /rails/storage
    volumes:
      - campfire-data:/rails/storage

Because the accessory shares the host volume with the main service, it can read the same SQLite files — that is how a backup process can see live data without the upstream image knowing anything about it.

Kamal vs Docker Compose

If you already run your services with a docker-compose.yml, Kamal is worth understanding as the same idea with a different philosophy: Compose describes a set of containers and leaves the rest to you, while Kamal treats each deployment as a supervised, healthchecked, TLS-terminated service with a rollout strategy.

The practical differences:

  • No build required. Kamal can pull a published image, which is what makes the prebuilt-image pattern in this post possible.
  • One command per app, one file per app. deploy.yml instead of a section of a shared Compose file, and kamal deploy instead of remembering which profile to pass.
  • Healthchecks drive the rollout. A container that fails its check never receives traffic.
  • Traefik replaces your reverse proxy. Kamal ships a proxy that handles TLS and host routing, so you do not maintain Caddy or nginx configuration alongside it.

If your services are already running under Compose and working well, there is no reason to migrate. Kamal earns its place when you want deployments that are declarative, healthchecked, and reversible without babysitting.

Why I Use This Pattern

Self-hosting third-party software through Kamal keeps your deployment configuration in one place: a deploy.yml in version control, alongside the rest of your infrastructure. The same workflow handles everything you run — apps you built, apps you didn’t, accessories, sidecars — and there is one fewer long-lived service to maintain on your server.

When a new release of Campfire — or Plausible, or any other self-hosted app — lands, you bump a tag, run one command, and Kamal handles the rollout. That’s a small, predictable surface area, which is exactly what I want from infrastructure I don’t have time to babysit.

One last thing worth saying: nothing in this post is Rails-specific. Kamal doesn’t care what’s inside the container — Rails, Django, Phoenix, Go, Node, a static site, anything else. If you can package your application as a Docker image, you can deploy it with Kamal.

For complete Kamal documentation, head to kamal-deploy.org. The prebuilt-image pattern isn’t explicitly called out, but every configuration option and command this post relies on is documented in detail.