Using Kamal with GitHub Container Registry (ghcr.io)

• 8 min read

Kamal was one of the defining features of Rails 8 with the promise of “No PaaS required”, but of all the new Rails stuff I experimented with, I put this one off the longest. Deployment always seemed to be a DevOps dark art and something with high potential cost if not done properly.

I returned to Kamal recently determined to sit down with the docs and at least try it once. After a few obstacles along the way and some “gotchas”, which I’ll explain below partly as a way to document this for myself, I found Kamal to be everything promised and also an excellent dev experience (when you finally know what you’re doing). I plan to use this for all future Rails apps as opposed to a PaaS as I would have resorted to previously.

Setup

Server and domain

  • You’ll need a server to deploy to with SSH access. There are many VPS providers out there, but I used Hetzner because they seemed cheapest and easiest to set up.
  • Kamal gem and deploy.yml (this should already be setup for new Rails 8 apps)
  • A domain name pointed to your server, this should just be a DNS A record with the IP of your new server from above

deploy.yml Configuration

This is where I made the majority of my mistakes. First a quick overview of what each part does:

  • image should be {username}/{project_name}, this will be like the repo of your docker image
  • servers/web is the IP of the server to deploy to, same one you used for the DNS step
  • proxy/host is the domain name of the server to deploy that you set up earlier
  • registry/username is your dockerhub username, dockerhub is just like a github for docker images (more on this later)
  • proxy/ssl: true enables automatic Let’s Encrypt SSL certificates, you want this to be true

.kamal/secrets Configuration

  • Mostly setup out the box but more can be added
  • Names have to match the names referenced in deploy.yml
  • Can use external commands like cat or 1password
  • Some examples:
    KAMAL_REGISTRY_PASSWORD=$(bin/rails runner "puts Rails.application.credentials.dig(:github, :token)") # From rails credentials KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD # From environment RAILS_MASTER_KEY=$(cat config/master.key) # From file

Choosing GitHub Container Registry over Docker Hub

A note about docker image registries:

Dockerhub is the default and it does work well out of the box, however you are only allowed one private repo on the free plan which obviously is not sustainable. You can sign up for a paid plan, but to me that defied the reason for using Kamal at all. Why pay another service if the plan is to ditch Heroku, Render etc by deploying yourself anyway?

GitHub has a container registry service too, which makes sense if you’re already in that ecosystem, but this wasn’t as straightforward as Dockerhub and I had a few headaches along the way to get it setup as the documentation seems lacking for non-dockerhub setup. It all works fine without issues if you follow these steps:

⚠️ CRITICAL: Use a Classic Personal Access Token

This is the gotcha that caught me out:

  • Get a PAT from Settings > Developer settings
  • You MUST generate a classic token with write:packages and read:packages permissions
  • The new fine-grained tokens will not work - you’ll get authentication errors

Here’s the config that actually works:

# Name of your application. Used to uniquely configure containers. service: your_app_name # Name of the container image. image: {repo_owner}/{repo_name} registry: server: ghcr.io username: {github_username} # This just references the secrets file from above password: - KAMAL_REGISTRY_PASSWORD

Run kamal setup and you should be good to go!

Git Repository ↔ Docker Image Relationship

This is crucial to understand the Kamal workflow, and something that caught me out multiple times when my changes weren’t being deployed.

How It Works

  1. Git is the source of truth: Kamal uses your current Git commit SHA as the Docker image tag
  2. Image naming: Your image becomes {repo_owner}/{repo_name}:<git-commit-sha>
  3. Remote requirement: Your code must be pushed to a Git remote before deploying
  4. Build process: Kamal builds a Docker image from your current commit
  5. Registry storage: The image is pushed to your configured registry (ghcr.io in our case)
  6. Server deployment: Kamal pulls this specific image version to your servers

Common Symptoms When You Forget to Push

  • Your changes aren’t visible after deployment
  • The deployment succeeds but nothing has changed
  • You see an old commit SHA in the deployment logs

The Correct Workflow

# 1. Make your changes git add . git commit -m "Add new feature" # 2. Push to remote (REQUIRED before deploy) git push origin main # 3. Deploy with Kamal kamal deploy # Or set up deployment hooks on merge for automation

Zero-Downtime Deployments

Kamal deployments are zero-downtime by design.

This was one of those “I’ll believe it when I see it” features, but watching it work in real-time is quite satisfying.

The Zero-Downtime Process

  1. New container starts: Kamal builds and starts a new container alongside the old one
  2. Health check wait: Waits for the new container to respond 200 OK to GET /up
  3. Traffic switch: kamal-proxy redirects traffic from old → new container
  4. Old container cleanup: Stops and removes the previous container
  5. No dropped connections: Existing requests complete on the old container

Health Check Requirements

Your Rails app must respond to GET /up for this to work. Rails 8 includes this by default:

# config/routes.rb (automatically included in Rails 8) Rails.application.routes.draw do get "up" => "rails/health#show", as: :rails_health_check end

Basic Kamal Commands

Deployment Commands

kamal setup # First-time setup (installs Docker, sets up proxy) kamal deploy # Build image and deploy kamal redeploy # Deploy without rebuilding (if image exists) kamal rollback # Rollback to previous version

Monitoring & Debugging

# View application logs kamal app logs -f # Follow logs in real-time kamal app logs -n 100 # Show last 100 lines kamal app logs --since 1h # Show logs from last hour # Check application status kamal app details # Show running containers kamal app containers # List all containers kamal proxy details # Check proxy status # Access your application kamal console # Rails console kamal shell # Bash shell kamal dbc # Database console

Server Management

kamal server exec "systemctl status docker" # Run commands on server kamal proxy boot # Start/restart proxy kamal proxy logs # View proxy logs

Build & Registry

kamal build deliver # Build and push image only kamal registry login # Login to Docker registry

Troubleshooting

During my setup, I hit a few snags that might help others:

net-pop Ruby Version Error

If you encounter errors related to net-pop during kamal setup, it’s likely a Ruby version mismatch:

# Solution that worked for me: bundle update net-pop # Updated to ruby 3.3.4 # IMPORTANT: update both .ruby-version and Dockerfile ARG RUBY_VERSION rm Gemfile.lock && bundle install

When Things Go Wrong

My debugging checklist when deployments fail:

# Container won't start? kamal app details # Check container status kamal app logs # Check application logs for errors # Deployment fails? kamal build deliver # Test build process separately kamal setup # Re-run setup if needed # SSL certificate issues? kamal proxy details # Check Let's Encrypt status kamal proxy logs # Check proxy logs for cert errors

Conclusion

What started as an intimidating “DevOps dark art” has become my go-to deployment method. The journey from deployment anxiety to confidently running git push && kamal deploy has been worth it.

Yes, there were gotchas (looking at you, GitHub token types), and yes, I spent time debugging issues that seemed obvious in hindsight. But now I have:

  • Zero-downtime deployments that actually work
  • Complete control over my infrastructure
  • No monthly PaaS bills
  • A deployment process that’s as simple as any PaaS

If you’re on the fence about trying Kamal, especially with GitHub Container Registry, I’d encourage you to give it a shot. The initial setup investment pays off quickly, and you’ll gain a much better understanding of how your application actually runs in production.

Cost is another major benefit, I’m spending around 3 EURO per month for my current setup to run several small rails apps with sqlite databases whereas a PaaS would be charging 10-20x that.

For my next projects? It’s Kamal all the way.