How to ship Go apps with Kamal

Published on November 13, 2024

I have been shiping most of my Go apps with Kamal for a while now. I am sharing the main config files I am using, with two different apps: this blog and a object storage service.

What is Kamal?

Quoting the definition from the Kamal website: “Kamal offers zero-downtime deploys, rolling restarts, asset bridging, remote builds, accessory service management, and everything else you need to deploy and manage your web app in production with Docker. Originally built for Rails apps, Kamal will work with any type of web app that can be containerized.”

Practically, it allows for setting up the service on one or multiple bare servers and shipping the app there. And because Go builds into a binary, I am only shipping that to the server, embedded into a very minimal Docker image.

For details of the setup, Kamal commands, and general documentation, please read the documentation on the Kamal website.

Configuration for this blog

Kamal can use an elaborate setup, but I have kept things simple. Here’s the deploy.yml:

    
      service: blog

      image: edimoldovan/blog

      servers:
        web:
          - 37.27.83.82
      
      proxy: 
        ssl: true
        host: eduardmoldovan.com
        app_port: 8000
      
      registry:
        username: edimoldovan
      
        password:
          - KAMAL_REGISTRY_PASSWORD
      
      builder:
        arch: arm64
  

Simple and concise — it only contains a few lines, configuring the IP to ship to, name of the service, port for the proxy to link to, the domain, and the Docker registry access password.

This last bit, the KAMAL_REGISTRY_PASSWORD is configured in the secrets file:

    
      KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
  

There is only one additional file I am using, the Dockerfile:

    
      # The build stage
      FROM golang:1.23 AS builder
      WORKDIR /app
      COPY . .
      RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -a -installsuffix cgo server.go

      # The run stage
      FROM scratch
      WORKDIR /app
      COPY --from=builder /app/server .
      EXPOSE 8000
      CMD ["./server", "-port", "8000"]
  

As you can see, I have configured a builder with the latest version of Go. This builds the binary, which is then copied into the image that runs it. Otherwise, the file contains only standard Docker config.

Configuration for an object storage service I built

This is a service that works like an S3 bucket and performs all operations through an API. Most Kamal and Docker settings are the same, with the addition of mounted folders for the SQLite database and the files folder where objects are stored.

Here’s the deploy.yml:

      
      service: object-storage

      image: edimoldovan/object-storage

      servers:
        web:
          - 37.27.83.82

      proxy: 
        ssl: true
        host: objects.makros.app
        app_port: 8080

      registry:
        username: edimoldovan
        password:
          - KAMAL_REGISTRY_PASSWORD

      builder:
        arch: arm64

      env:
        clear:
          SOME_OTHER_VAR: 123
        secret:
          - MAKROS_OBJECT_STORAGE_TOKEN

      volumes:
        - "/root/storage/sqlite:/app/sqlite"
        - "/root/storage/files:/app/files"
    

And here’s the Dockerfile:

      
      # The build stage
      FROM golang:1.23-alpine AS builder
      WORKDIR /app
      COPY . .
      RUN apk add --no-cache gcc libc-dev
      RUN CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -a -installsuffix cgo main.go
      
      # The run stage
      FROM alpine:latest
      WORKDIR /app
      RUN apk add --no-cache sqlite-libs
      COPY --from=builder /app/main .
      RUN ls -l /app/main
      EXPOSE 8080
      CMD ["./main", "-port", "8080"]
    

That's it. Each time I run kamal deploy these get shipped from my laptop directly to the Hetzner machines.