Deploying with Kamal on Akamai/Linode

There is a version of self-hosting that looks beautifully simple on a whiteboard. You rent a VPS and build a container, point DNS at the host, run your Kamal build and deploy, and you get an easy-to-deploy clean HTTPS site with almost no drama.

And honestly, that is not a fantasy (if you don’t make some of the first rookie mistakes I made lol). Kamal is a great fit for small, understandable production environments. You can get a long way without introducing the weight of a much larger orchestration stack.

I’ve been using Heroku for a long time, so this was a shift in my process. That meant relearning a few things and moving back to a simpler build structure. Irony is that it cut my complexity and deploy down by a huge amount.

The problem is that the easy diagrams usually skip the part where real production habits begin for running multiple web apps:

  • more than one app on a host
  • DNS split across multiple zones or teams
  • TLS renewals that need to stay boring
  • future apps that should follow the same pattern instead of inventing a new one every time

This is the deployment pattern I recommend now for running Kamal-managed applications on Akamai/Linode with automatic HTTPS, especially when you want to do it correctly from the first deployment instead of cleaning it up later.

Why I Like This Pattern

What I want from a deployment model is pretty simple:

  1. It should be easy to reason about.
  2. It should not require a huge control plane.
  3. It should support production TLS cleanly.
  4. It should scale to multiple apps on one host without becoming a conflict-inducing nightmare.
  5. It should be easy to teach to another operator later.

That is why this Kamal pattern works so well for me because it forced me to go back to first principles and build an architecture that is intentionally straightforward:

  • one Akamai/Linode VPS
  • Docker on the host
  • one shared kamal-proxy
  • one Kamal-managed app per route
  • Let’s Encrypt for host-specific certificates
  • DNS records pointing directly at the VPS

That may not be the fanciest architecture in the world, but it is practical and easy to support as apps continue to grow. Not to make cost a big part of the decision, but there were some very good financial benefits that arrived after changing my deployments. Yay!

The Design Principle That Matters Most

If you remember only one thing from this whole post, it is to treat the hostname list in your Kamal proxy config as production state, not just a convenience field. Every hostname you expect to work should be explicitly declared.

If it is missing, the proxy may still be reachable, Docker may still be healthy, and your app may still be running, but the browser will not care. You will get TLS failures, host mismatch behavior, or “unknown server name” style problems long before your application code ever sees a request. Guess how I found out about that? 😭

That is why I standardized each deployment with explicit hosts and a predictable HTTPS model. Simplicity + consistency FTW!

The Deployment Pattern

For a standard single-host production deployment on Akamai/Linode, this is the baseline I built from:

service: myapp
image: your-registry-user/myapp

servers:
  web:
    - your.vps.ip.address

ssh:
  user: username

proxy:
  hosts:
    - app.example.com
  app_port: 80
  ssl: true
  forward_headers: true
  healthcheck:
    path: /up

registry:
  server: 127.0.0.1:5555
  username: "<%= ENV.fetch(\"KAMAL_REGISTRY_USERNAME\", \"kamal\") %>"
  password: "<%= ENV.fetch(\"KAMAL_REGISTRY_PASSWORD\", \"kamal\") %>"

builder:
  arch: amd64
  remote: ssh://username@your.vps.ip.address
  local: false
  context: .

There are a few things here that are worth calling out.

proxy.hosts

Use hosts, not assumptions. If your app should answer for:

  • app.example.com
  • www.example.com

then both names need to be present in proxy.hosts. This seems small, but many forget the additional www CNAME on top of their regular root domain.

If you only want the app to own one hostname, keep the list to one hostname. That sounds obvious, but it is the easiest way to keep the proxy, DNS, and certificate behavior aligned from day one.

ssl: true

For a single-host deployment, this is the simplest path to automatic Let’s Encrypt certificates.

Kamal Proxy handles the certificate lifecycle directly, which means you do not have to front the host with an extra reverse proxy layer just to get HTTPS working.

forward_headers: true

This makes sure your application sees the right forwarding headers for things like:

  • scheme detection
  • client IP awareness
  • secure URL generation

If you are running Rails behind the proxy, you will definitely want this.

healthcheck.path

Make the healthcheck explicit. Don’t rely on future-you remembering what the app expects during a deployment or recovery.

If /up is the right health endpoint, make sure to put it in your config.

Registry Pattern for a Small VPS

One really useful trick for this style of deployment is to run a small Docker registry directly on the VPS.

That lets the remote builder and the target host use the same local registry endpoint, which keeps the image path simple and avoids some of the more annoying SSH-forwarding edge cases.

A minimal host-local registry will look like this:

ssh username@your-vps 'docker run -d -p 5555:5000 --restart unless-stopped --name kamal-registry registry:2'

This is a simple and practical option for a lean single-host setup.

DNS: Keep It Boring

The cleanest deployment path is:

  1. create the DNS record for the exact hostname
  2. point it directly at the VPS
  3. wait for propagation
  4. verify the hostname resolves correctly
  5. only then run kamal deploy

The key phrase there is exact hostname. Trust me…don’t mess this up. If you want:

  • app.example.com

then create a record for app.example.com.

If you later want:

  • www.example.com

then create that record too and test it intentionally. Do not assume that because one hostname works, every alias for that domain is automatically ready for Kamal-managed TLS.

First Deployment Walkthrough

If you want the shortest path from zero to a working deployment, this is the sequence I would use.

1. Prepare the Linode Host

Install Docker on the VPS and verify you can SSH in cleanly.

You want:

  • Docker running
  • ports 80 and 443 reachable
  • SSH working without drama

2. Create the DNS Record

Before the first deploy:

dig +short app.example.com

You want the record to resolve to the VPS before you ask Kamal to issue a certificate.

3. Verify Plain HTTP Reaches the Host

curl -I http://app.example.com

You do not need the final app response yet. You just need to confirm the hostname is reaching the server you think it is reaching.

4. Deploy

bundle exec kamal deploy

For a clean first deploy, Kamal should:

  • build and push the image
  • start the app container
  • configure the proxy route
  • request and attach the certificate

5. Verify the Result

After deploy, run:

curl -I https://app.example.com
echo | openssl s_client -connect app.example.com:443 -servername app.example.com 2>/dev/null | openssl x509 -noout -subject -issuer -dates
ssh username@your-vps 'docker exec kamal-proxy kamal-proxy list'

Those three checks answer the questions that matter:

  • does the app respond?
  • is the certificate valid?
  • does the proxy state match the hostname you intended?

Word to the wise: test in multiple browsers. Chrome forces HTTPS, but it also is strangely lazy about TLS intermediates so the site may work on Chrome but fail on Safari.

The Shared-Host Rule

If you plan to run more than one app on the same VPS, the design still works well, but you need one extra bit of discipline, Each app owns only the hosts listed in its own proxy.hosts.

That means the proxy becomes the traffic map for the box. It keeps routing explicit and prevents the kind of “everything works until it suddenly doesn’t” behavior that shows up when hostnames are implied instead of declared.

In practice, that means:

  • app A gets app-a.example.com
  • app B gets app-b.example.com
  • each app has its own route and certificate lifecycle

That is a lot easier to operate than trying to blur host responsibility across multiple services.

Verification Commands I Use Every Time

When I am deploying or troubleshooting, I do not trust one green check.

I want to verify:

  • DNS
  • HTTP reachability
  • HTTPS behavior
  • certificate identity
  • proxy routing state

These are the checks I come back to constantly but you have this blog now to save you the time 🙂

dig +short app.example.com

curl -I http://app.example.com
curl -I https://app.example.com

echo | openssl s_client -connect app.example.com:443 -servername app.example.com 2>/dev/null | openssl x509 -noout -subject -issuer -dates

ssh username@your-vps 'docker exec kamal-proxy kamal-proxy list'
ssh username@your-vps 'docker logs --tail 200 kamal-proxy'
ssh username@your-vps 'docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"'

That gives you a very fast picture of whether the failure is in, including the DNS, routing, certificate health, and the container details.

What to Avoid from the Start

There are a few habits that create trouble later…this is entirely a self-report from hacking my way through a couple of partial deployments.

1. Adding Hostnames Before You Are Ready to Serve Them

If a hostname is going to be served by Kamal, make that a deliberate change. Do not casually add aliases into proxy.hosts during unrelated deployment work. New hostnames means:

  • new routing expectations
  • new ACME issuance behavior
  • new validation requirements

It’s an easy step, but I neglected to do this in the right order early and it triggered some TLS issues (unsurprisingly).

2. Relying on Implicit Redirect Assumptions

A DNS CNAME is not the same thing as a fully working HTTPS path on the proxy. If you want a hostname to be browser-safe all the way through TLS, test that hostname directly.

3. Leaving Proxy State Undocumented

If you ever make a temporary proxy change during an incident, document it immediately. Kamal is elegant in its simplicity, but the proxy state is still real production state. If it drifts away from the repo and nobody notices, the next deploy becomes much harder to reason about.

A Good Default Workflow for Future Apps

This is the workflow I would teach to someone new on the team:

  1. create the app repo
  2. configure Kamal with explicit proxy.hosts
  3. create the DNS record for the exact hostname
  4. verify DNS resolution before deploy
  5. run kamal deploy
  6. verify HTTPS and certificate metadata
  7. inspect kamal-proxy list
  8. record the hostname and deployment pattern for the next app

It is just a good operational habit that I’ve built up as I spin up new app builds. And that is usually what makes small self-hosted environments successful over time. Consistency is key.

Final Thoughts

What I like about Kamal on Akamai/Linode is that it stays understandable. You can build a very solid production setup without disappearing into a maze of moving parts. But that simplicity only pays off if you keep your intent explicit:

  • exact hostnames
  • predictable TLS
  • repeatable verification
  • documented proxy state

Do that from the first deployment and the whole model gets a lot calmer. Hope that this helps you, and happy deploying!

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Sign In

Register

Reset Password

Please enter your username or email address, you will receive a link to create a new password via email.