Deploying Prebuilt Docker Images with Kamal
Kamal isn't just for apps you build yourself. It works just as well for prebuilt third-party Docker images — here's the pattern I use for self-hosted apps.
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
To roll out a new version of Campfire when one is released, bump the image tag in deploy.yml and run kamal deploy again. Kamal pulls the new image, starts a new container alongside the old one, waits for healthchecks to pass, then swaps traffic. If the new container fails its healthcheck, the old one keeps serving requests.
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.
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.