🇯🇵 Tokyo is live! 🚀 Launch your VPS and enjoy 2 months off — use code KONNICHIWA50 🎉 Get Started Today →

Kamal on a VPS: Docker Deploys Without Kubernetes

Isometric VPS, container package, globe, and access key illustrating container deployment infrastructure

A Kamal deployment can fail at several independent layers: the container image, SSH access, the registry, application health checks, DNS, or TLS. This guide shows how to validate each layer, deploy a containerized application behind Kamal Proxy, and test a rollback before relying on the VPS in production.

The examples use Kamal 2.12.0, an AMD64 VPS, and Ubuntu 24.04 LTS. Adapt the app-specific values and test the workflow on a staging VPS before using it for production. The configuration has been checked against the current Kamal documentation; it is a template, not a report of a benchmark or live deployment.

Prepare and Test the Application Container

Before changing the server, confirm that you have the required components: a Dockerized web application, a Linux VPS with a public IP and SSH-key access, a domain you control, and a container registry. If you are new to running containers on your own server, review the fundamentals of Docker VPS hosting before continuing.

Architecture matters. An ARM64 image built on an Apple Silicon or ARM Linux workstation will not run natively on an AMD64/x86-64 VPS. Check the target server with uname -m: x86_64 means AMD64, while aarch64 means ARM64. The deployed image must include the target platform.

Size the VPS for deployment peaks rather than steady-state usage. During a rollout, old and new application containers can briefly coexist while Docker retains image layers and logs.

WorkloadWhat to measure before sizing
App onlyPeak RAM and CPU, image size, and deployment overlap
App + workerApp requirements plus worker concurrency and memory
App + databaseApp requirements plus database RAM, disk growth, and I/O
App + worker + databaseCombined peaks, storage growth, and backup space

Measure peak consumption and allow deployment headroom. The right plan depends on the application, worker concurrency, database load, image size, and traffic.

Keeping the database external simplifies the first deployment. If you colocate it on the VPS, you remain responsible for off-server backups, restore testing, storage monitoring, and ensuring the database has enough memory during overlapping deployments. Place the VPS near both the users and the database; proximity to your workstation primarily affects administrative latency.

Make the Container Deployable

Test the exact production image locally before troubleshooting the VPS. This separates container build and startup errors from registry, SSH, proxy, and server problems.

Validate the Production Start Path

The Dockerfile needs a non-interactive production command, declared dependencies, and no reliance on source files or credentials mounted from your workstation. The application must bind to 0.0.0.0. A service bound to 127.0.0.1 inside its container is unavailable to the proxy on the Docker network.

This Node.js example exposes internal port 3000:

FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENV HOST=0.0.0.0 PORT=3000
EXPOSE 3000
CMD ["npm", "start"]

This example assumes that the application does not require a separate build step or development dependencies at runtime. Applications that compile TypeScript, frontend assets, or native modules should use a suitable multi-stage Dockerfile. The application must actually read HOST and PORT, or configure those settings in its server code. Add a .dockerignore that excludes credentials, .git, local dependencies, and environment files from the build context.

Build and test the image with the current Docker CLI syntax:

docker build --platform linux/amd64 -t app:test .
docker run --rm -d --name app-test -p 127.0.0.1:3000:3000 app:test
curl -i http://127.0.0.1:3000/

Check the container with docker ps --filter name=app-test; expect app-test to show Up. On an ARM64 development machine, --platform linux/amd64 targets an AMD64 VPS. Use a compatible CI runner or remote builder if emulation cannot build native dependencies reliably.

Make Health Checks Proxy-Safe

Provide a lightweight endpoint such as GET /up that returns 200 OK without authentication, redirects, rate limiting, or optional third-party dependencies. A representative response is:

For example, /up can return status 200, content type text/plain, and body OK.

The endpoint should confirm that the application can accept traffic. Avoid making readiness depend on optional analytics, email, or other external services. If a database is essential for every request, the health check may verify basic database availability, but keep the check fast and avoid expensive queries.

Test it through the published local port:

curl -i http://127.0.0.1:3000/up

Expect an unredirected 200 response.

Secure the VPS and Verify SSH Access

Conceptual VPS infrastructure and access key illustrating controlled deployment access

Use a clean Ubuntu 24.04 LTS server and apply available security updates. Kamal connects over SSH; the account must be able to manage Docker. This guide uses a preconfigured deploy account with Docker already installed.

The VPSus DevOps server configuration guide provides additional background on server preparation and deployment requirements.

Test the Deployment User

Kamal defaults to root. For the non-root deploy account used here, an administrator should first install Docker using the official Ubuntu installation instructions, install the SSH key, and grant the account access to Docker. Membership in the Docker group grants root-level privileges; a separate username does not create a security boundary from the daemon. Keep provider console or recovery access available while changing SSH settings.

Before disabling password authentication, open a second terminal and verify key-based, non-interactive access:

ssh-add -l
ssh -o BatchMode=yes deploy@SERVER_IP 'id && docker version'

Replace SERVER_IP with the VPS address. The remote command must print the expected account and both Docker client and server details without prompting for a password. A client-only response or daemon permission error is a failed prerequisite. Reconnect after changing group membership, and keep the existing login method until the replacement has been tested.

Expose Only the Proxy

The application can listen on port 3000 inside Docker without publishing that port on the VPS’s public interfaces. Public ingress should normally be limited to SSH and Kamal Proxy.

PortServicePolicy
22/TCPSSHPublic; restrict to administrative source IPs when practical
80/TCPHTTPPublic
443/TCPHTTPSPublic
3000/TCPApplicationPrivate
5432/TCPPostgreSQLPrivate
6379/TCPRedisPrivate

Ports 80 and 443 need public ingress for web traffic and certificate handling. Restrict port 22 to a stable office address, VPN, or management network when operationally practical.

After Docker is installed, inspect actual listeners and published ports:

sudo ss -lntup
docker ps --format 'table {{.Names}}\t{{.Ports}}'

Confirm that application, database, and cache ports are not published on the host at 0.0.0.0 or [::]; the application still binds to 0.0.0.0 inside its own container. Docker changes packet-filtering rules, so inspect accidental mappings such as 5432:5432 even if a host firewall appears restrictive.

Configure DNS and Registry Credentials

Kamal Proxy needs a publicly resolving hostname before it can obtain a trusted HTTPS certificate. Create an A record such as app.example.com that points to the VPS’s public IPv4 address. Add an AAAA record only if the server has correctly configured public IPv6. An incorrect AAAA record can direct IPv6-capable clients to the wrong server. This single-server example uses the documented automatic TLS mode; DNS must point directly to the server and the ACME challenge must be reachable on port 443.

Verify the records from outside the VPS:

dig +short A app.example.com @1.1.1.1
dig +short AAAA app.example.com @1.1.1.1
curl -I http://app.example.com

The first command should return the VPS IPv4 address. The second should be empty unless IPv6 was intentionally configured. Before a service listens on port 80, curl may return a connection error; that does not by itself distinguish a closed firewall from the absence of a listener. Confirm the provider firewall, host firewall, and server listeners separately.

Create the private repository before the first push and scope its credentials to the required repository. The simple registry configuration below uses one credential for Kamal’s local and remote logins; verify its push and pull permissions and rotate it deliberately.

Treat release tags as immutable. A tag such as myapp:4f31c2a should identify one build, while myapp:latest can change and is unsuitable as the sole rollback reference. Keep token values and application secrets out of config/deploy.yml and Git.

Initialize Kamal and Configure deploy.yml

Pinning the Kamal CLI prevents an unplanned upgrade from changing deployment behavior. The following commands install the version used by this guide and create the configuration skeleton:

gem install kamal -v 2.12.0
kamal version
kamal init

Use a working Ruby environment on the deployment workstation. Verify that kamal version reports 2.12.0; if another version is selected, resolve that before continuing. Run kamal init only in an application that has not already been initialized, then inspect .kamal/secrets and config/deploy.yml. See the installation guide for prerequisites.

Map the Application to the VPS

The configuration below assigns one web role to the tested server, routes the hostname through Kamal Proxy, and builds an AMD64 image for an x86-64 VPS. Replace the documentation IP, domain, registry, repository, and username. NODE_ENV assumes a Node.js app; retain DATABASE_URL only if the application needs a database, and add its other required variables. These example addresses are not working infrastructure.

For a custom registry, keep the registry hostname in registry.server and use the repository path in image. Repeating the registry hostname in both values can produce an incorrect fully qualified image name.

service: myapp
image: team/myapp
servers:
  web:
    - 203.0.113.10
ssh:
  user: deploy
proxy:
  host: app.example.com
  ssl: true
  app_port: 3000
  healthcheck:
    path: /up
registry:
  server: registry.example.com
  username: registry-user
  password:
    - KAMAL_REGISTRY_PASSWORD
env:
  clear:
    NODE_ENV: production
  secret:
    - DATABASE_URL
builder:
  arch: amd64

The builder.arch setting prevents an Apple Silicon workstation from unintentionally producing an ARM-only image for an AMD64 server. The service value namespaces containers and related resources, while image identifies the repository containing the releases. Use separate service names or destination overrides when multiple environments share infrastructure.

Keep Secrets Out of the Configuration

Keep secret values out of deploy.yml. List the names under env.secret and explicitly map the exported values in .kamal/secrets, as described in Kamal’s environment-variable documentation. Populate the workstation environment through your password manager or protected CI secret store before invoking Kamal. Exporting a variable alone does not replace this mapping:

KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
DATABASE_URL=$DATABASE_URL
printf '%s\n' '.kamal/secrets' >> .gitignore
git check-ignore -v .kamal/secrets

Expect git check-ignore -v .kamal/secrets to report a matching ignore rule. Before committing, also inspect git diff --cached to ensure that no credentials or generated environment files have been staged. An ignore rule does not remove an already tracked secret: inspect git ls-files .kamal/secrets, and investigate any output before committing.

Validate the Effective Configuration

Inspect the effective configuration locally before deployment. Its output can contain resolved secrets; do not paste it into tickets, public logs, or the article:

kamal config

The command should exit with status 0. Confirm the service, web server, proxy host, port 3000, /up health path, custom registry, repository path, and amd64 builder architecture. If you use a destination such as staging, validate that configuration separately with kamal config -d staging.

Run Setup and Deploy a New Release

Before the new app passes its health check, the proxy routes traffic to the old app; afterward it routes traffic to the new app
Traffic routing before and after the new release passes its health check. The old app serves requests while the new app is checked.

Once the configuration is valid and the required secrets resolve locally, run the initial bootstrap from the application directory:

kamal setup

kamal setup performs the initial deployment and can bootstrap Docker where the SSH account permits it. Here, Docker and access were prepared beforehand. Treat SSH, registry login, image build/pull, proxy startup, and application readiness as separate stages when reading the output.

Verify the result with kamal app details; the application container should be running on the web server. Read setup output as a sequence of independent stages. For example, a completed image push confirms registry publishing, while successful remote commands confirm SSH access. This makes it easier to identify the first failed layer.

Use kamal setup for initial provisioning. Run kamal deploy for normal releases. If setup fails, record the failed stage and release identifier before troubleshooting. Redact credentials, secret values, server addresses, and SSH details before sharing logs.

Ship a Committed Release

Test the normal release path with a visible change, such as displaying the Git SHA in the application footer. Commit the change so that the release identifier corresponds to a known source revision.

git add -p
git diff --cached
git commit -m "Show release version"
git status --short
kamal deploy

Confirm that git status --short is empty before deployment. With the configuration used here, kamal deploy reads the existing config/deploy.yml; no destination or architecture flag is needed.

The deployment sequence builds and transfers the new image, starts its container, then switches proxy traffic after a successful health check. The old release serves traffic while the replacement is checked; the old container is stopped after the handoff. Keep enough RAM and disk for overlap, and make database migrations and assets compatible across both versions.

These checks gate the deployment. They are not continuous uptime monitoring after the switch; configure separate monitoring for the running service.

Record the release SHA and verify the public application:

git rev-parse HEAD
curl -fsS https://app.example.com/

Compare the Git SHA with the version reported by Kamal. The HTTPS response should contain the visible release marker added for this test.

Isolate Deployment Failures by Layer

A deployment is not verified until an external client reaches the expected release over trusted HTTPS. Run these commands from a machine outside the VPS:

curl -Iv https://app.example.com/
curl -fsS https://app.example.com/
curl -fsS https://app.example.com/up

Expect successful certificate verification, the new release marker, and OK from /up. If a command fails, inspect Kamal with kamal app details, kamal app logs, and kamal proxy details. Use docker ps, docker inspect, and docker logs directly on the VPS when Kamal’s output does not isolate the problem.

Troubleshoot in this order:

  1. Confirm that SSH connects non-interactively.
  2. Confirm that the image builds for the target architecture.
  3. Confirm that the registry push succeeds.
  4. Confirm that the server can authenticate and pull the image.
  5. Confirm that the container remains running.
  6. Confirm that /up responds on the configured internal port.
  7. Confirm that Kamal Proxy routes the configured hostname.
  8. Confirm that public DNS points to the VPS.
  9. Confirm that the certificate is trusted and matches the hostname.

This order prevents DNS or TLS symptoms from triggering unnecessary image rebuilds.

If the Container Never Becomes Healthy

Separate a container that never started from one that started but failed its health check. For startup failures, inspect the production command, required environment variables, image architecture, runtime dependencies, memory pressure, and free disk space.

For a running container, test /up from the relevant Docker network context. Check the 0.0.0.0 binding, app_port, redirects, host authorization, rate limiting, and mandatory startup dependencies. Match application logs to the unhealthy release’s timestamp rather than searching unrelated historical entries.

If the Container Is Healthy but the Domain Fails

Confirm that Kamal Proxy owns ports 80 and 443 and that Apache, Nginx, or another listener is not competing for those ports. The application port should remain private.

Next, compare the public A and AAAA records with the VPS addresses and confirm that proxy.host exactly matches the requested hostname. If HTTP routing works but trusted HTTPS fails, inspect certificate issuance and hostname matching rather than rebuilding the application.

Test Rollback and Production Readiness

Test rollback first on a staging or disposable application. Kamal rollback needs the previous image to remain on the server; it does not pull that image from the registry. List retained containers, then substitute the exact known-good version for the placeholder below:

kamal app containers -q
kamal rollback 'REPLACE_WITH_PREVIOUS_VERSION'
curl -fsS https://app.example.com/
curl -fsS https://app.example.com/up
kamal proxy details
kamal app logs

Confirm that the public page shows the previous release, /up returns OK, and the logs show no new startup errors. Check local container/image retention before relying on a rollback target; registry retention alone is insufficient.

Rollback changes the application image; it does not reverse database migrations. Use backward-compatible schema changes, such as expand-and-contract migrations, when old and new application versions may overlap. Maintain a separate database recovery plan with encrypted off-server backups and scheduled restore tests. A Docker volume provides persistent storage, not an independent backup.

Kamal handlesYou still own
Versioned application deployment, health-gated proxy switching, and rollback workflowDatabase recovery, monitoring, OS security, secrets, and log and image retention

Production-readiness checklist:

  • Trusted HTTPS and external health checks pass
  • Previous immutable image retained and rollback tested
  • Database backup stored off-server and restore tested
  • External uptime monitoring enabled
  • Container log rotation configured
  • Disk and memory alerts enabled
  • OS updates scheduled
  • Secret rotation documented
  • Database and cache ports remain private
  • Image cleanup policy preserves required rollback releases

Use suitable open-source infrastructure monitoring tools to watch uptime, resource pressure, logs, and deployment incidents after launch.

Confirm the Complete Deployment Path

A reliable Kamal deployment requires more than a successful kamal setup. Validate the production container before provisioning the server, restrict public ports, confirm DNS before enabling TLS, and use a health endpoint that accurately reflects application readiness. The deployment is ready for production only after a normal kamal deploy, an external HTTPS check, and a tested rollback all succeed.

âš¡ Spin up a Premium VPS in 2 minutes
17 locations worldwide
NVMe  Â·  Unmetered 1 Gbps  Â·  Full root access  Â·  From $10/mo

Frequently Asked Questions

What does a VPS need to run Kamal?

The VPS needs a supported Linux distribution, SSH access, enough privileges for Docker installation and management, adequate deployment headroom, and public access to ports 80 and 443 when Kamal Proxy manages HTTPS. The container image must match the server's CPU architecture.

Does Kamal automatically configure HTTPS?

Kamal Proxy can obtain and serve HTTPS certificates when ssl: true is configured, the hostname resolves to the VPS, and ports 80 and 443 are publicly reachable. Incorrect DNS, blocked ports, or another process occupying those ports can prevent certificate issuance.

What is the difference between kamal setup and kamal deploy?

kamal setup prepares a new host, installs Docker when necessary, starts Kamal Proxy, and performs the initial deployment. kamal deploy ships subsequent application releases using the existing configuration.

Why does a Kamal health check fail when the application works locally?

Common causes include binding the application to 127.0.0.1 instead of 0.0.0.0, configuring the wrong app_port, redirecting the health endpoint, requiring authentication, blocking the proxy's host header, or omitting required environment variables.

How do you roll back a Kamal deployment?

Use kamal app containers -q to identify a retained previous release, then run kamal rollback with that exact version. Its image must still exist on the server. Verify the public release marker, health endpoint, proxy status, and logs afterward.

Does a Kamal rollback reverse database migrations?

No. A Kamal rollback changes the application image but does not reverse database migrations. Use backward-compatible migrations and maintain a separate, tested database recovery procedure.

Can Kamal deploy an ARM64 image to an AMD64 VPS?

Not natively. Build an AMD64 image for an x86-64 server, build an ARM64 image for an ARM server, or publish a multi-platform image that includes the target architecture.
Facebook
Twitter
LinkedIn

Table of Contents

Get started today

With VPS.US VPS Hosting you get all the features, tools

Image