Kamal previously known as MRSK, is a deployment tool that makes it easy to build your website as docker image and deploy them on to a bare metal or a virtual machine. It takes care of making sure there are no down time while a new release is created.

Follow the installation guide and install kamal locally. At the time of this post, the latest version is v1.5.2.

From within your ASP.NET project directory, initialize kamal configuration by running kamal init. This will create few files. .env, config/deploy.yml, and some sample hooks in ./.kamal folder. Make sure to add .env and files in .kamal/ directory to your .gitignore and .dockerignore files.

.env
.kamal

Application dockerfile

A Dockerfile for asp.net mvc application that uses multi-stage builds and asp.net core version 8.0 might look like the one below.

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /source

# copy csproj and restore as distinct layers
COPY src/ExampleApp/ExampleApp.csproj ./app/ExampleApp/
RUN dotnet restore ./app/ExampleApp

# copy everything else and build app
COPY src/. ./app/
RUN dotnet publish ./app/ExampleApp -c release -o /app --no-restore

# final stage/image
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY --from=build /app ./
ENV ASPNETCORE_HTTP_PORTS=5000
EXPOSE 5000
ENTRYPOINT ["dotnet", "ExampleApp.dll"]

You should be able to run docker build -t example-app . and see the project build successfully.

Configure Kamal

I found that you can get intellisense for the config/deploy.yml file in Rider or VS Code if you add this comment to the top of the file. There is a pull request to support schema for the file thats not merged at the time of this post.

# yaml-language-server: $schema=https://raw.githubusercontent.com/kjellberg/mrsk/validate-with-json-schema/lib/mrsk/configuration/schema.yaml

This example configuration file shows how to configure kamal to build and deploy to a ubuntu virtual machine. I am going to be using a private github container registry to host the docker image.

# Name of your application. Used to uniquely configure containers.
service: example-app

# Name of the container image.
image: chekkan/example-app

# Deploy to these servers.
servers:
  - 77.xx.xx.xxx

volumes:
  - "data-protection-keys:/root/.aspnet/DataProtection-Keys"

# Credentials for your image host.
registry:
  # Specify the registry server, if you're not using Docker Hub
  server: ghcr.io
  username: chekkan
  # Always use an access token rather than real password when possible.
  password:
    - KAMAL_REGISTRY_PASSWORD

# Inject ENV variables into containers (secrets come from .env).
# Remember to run `kamal env push` after making changes!
env:
  secret:
    - CONNECTIONSTRINGS__EXAMPLEAPP

I’ve generated a Classic Github personal access token with write:packages permission and have stored it in .env file against the KAMAL_REGISTRY_PASSWORD key. This is why we won’t be checking in the .env file into our repository or make it available inside the docker container.

I’ve also got another entry for CONNECTIONSTRINGS__EXAMPLEAPP in the .env file for my database connection string.

Notice the volume data-protection-keys that is mounted to the /root/.aspnet/DataProtection-Keys path inside the container. This will mean that the secrets used by the asp.net application will be persisted between deployments. You can see the path where docker has created the volume path by running docker volume inspect data-protection-keys. For me, the mount path is /var/lib/docker/volumes/data-protection-keys/_data. The directory will contain xml files unique to your application. So, you don’t have to worry about using the same location for different docker container on the same host.

Configure Traefik

Traefik is a reverse proxy that runs on your host machine. It manages the traffic to your container. It also has built in support for generating and managing free lets encrypt certificates. We can configure this via kamal’s config/deploy.yml file.

# Configure custom arguments for Traefik. Be sure to reboot traefik when you modify it.
traefik:
  options:
    publish:
      - 443:443
      - 8080:8080
    volume:
      - /letsencrypt/acme.json:/letsencrypt/acme.json
  args:
    entrypoints.http.address: ":80"
    entrypoints.https.address: ":443"
    api.dashboard: true
    api.insecure: true
    certificatesResolvers.letsencrypt.acme.email: "harish@chekkan.com"
    certificatesResolvers.letsencrypt.acme.storage: "/letsencrypt/acme.json"
    certificatesResolvers.letsencrypt.acme.httpChallenge: true
    certificatesResolvers.letsencrypt.acme.httpChallenge.entryPoint: "http"

⚠️ Note that you will have to create the file /letsencrypt/acme.json on your host machine with the appropriate permission.

mkdir -p /letsencrypt && \
	touch /letsencrypt/acme.json && \
	chmod 600 /letsencrypt/acme.json

The acme.json file will be used to store the certificate that was retrieved after succesfull letsencrypt http challenge. With the configuration above, we’ve created a certificate resolver called letsencrypt that we can refer to from our application container.

---
labels:
  traefik.http.routers.example-app-web-http.rule: "Host(`example.com`)"
  traefik.http.routers.example-app-web.rule: "Host(`example.com`)"
  traefik.http.routers.example-app-web.tls: true
  traefik.http.routers.example-app-web.tls.certresolver: "letsencrypt"
  traefik.http.routers.example-app-web.tls.domains[0].main: "busynest.org"
  traefik.http.middlewares.httpsredirect.redirectscheme.scheme: "https"
  traefik.http.middlewares.httpsredirect.redirectscheme.permanent: true
  traefik.http.routers.example-app-web-http.middlewares: "httpsredirect"

Container labels are one of the ways traefik can be configured for a given docker container. In the example above, we are defining 2 routes for the host example.com. One of the route example-app-web is configured for https and the other one example-app-web-http is for http traffic. We also refer to the letsencrypt certificate resolver for the https route.

The last thing we are configuring is the http redirect scheme middleware. We assign the middleware to the http route example-app-web-http. This will make sure any traffic to the http url will be redirected to the https port.

In the above configuration, we’ve also enabled the traefik dashboard which is exposed on port 8080. You can navigate to the host machine’s ip address followed by the port to view the dashboard after kamal setup.

Configure Health check

Kamal requires the container to contain curl command in order to perform healthcheck which enables zero downtime deployment.

If your asp.net website doesn’t already have health check middleware, add the following to your Program.cs file.

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks();
...
var app = builder.Build();
app.MapHealthChecks("/healthz");

This is the minimum you require to have an endpoint at /healthz that returns a HTTP status 200 - OK result when the server is ready to respond to requests.

In your asp.net dockerfile, add the following entries.

# Dockerfile
RUN apt-get update \
    && apt-get install -y curl \
    && rm -rf /var/lib/apt/lists/*

HEALTHCHECK CMD curl --fail http://localhost:5000/healthz || exit

Then in your ./config/deploy.yml file, add the following…

# Configure a custom healthcheck (default is /up on port 3000)
healthcheck:
  path: /healthz
  port: 5000
  interval: 10s

Manual deployment

Execute the following from your repository root.

kamal setup

This command will ssh into your remote host machine, install and configure docker, push environment configurations, deploy and configure traefik, build and push docker image to your container registry, and finally run the application container.

This took a long time for me. But, subsequent deployments were more tolerable.

If you need to make configuration changes to the environment variables or secrets, execute kamal env push, then git commit those changes, kamal deploy to build and deploy the environment variables to the container. Kamal uses the git hash to tag your docker image, so if the git commit was same as your previously docker image tag, kamal deploy won’t build and push the code changes.

Github Workflow / Action

In your CI CD pipeline, you only need to execute the kamal deploy command. Any configuration changes, you can perform them from your local machine.

Modify ./config/deploy.yml to only build for amd64. This will make the deploy step a bit faster.

builder:
  remote:
    arch: amd64

Create a file for your Github Action. I’ve got a file at .github/workflows/dotnet.yml.

# This workflow will build a .NET project
name: .NET

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build: ...
  deploy:
    runs-on: ubuntu-latest
    needs: build
    if: github.ref == 'refs/heads/main'
    steps:
      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: "3.3" # Not needed with a .ruby-version file
          bundler-cache: true # runs 'bundle install' and caches installed gems automatically
      - name: install kamal
        run: gem install kamal -v 1.5.2
      - uses: actions/checkout@v4
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      - name: Expose GitHub Runtime for cache
        uses: crazy-max/ghaction-github-runtime@v3
      - uses: webfactory/ssh-agent@v0.9.0
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
      - name: kamal deploy
        run: kamal deploy
        env:
          KAMAL_REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}

I’ve skipped section for building the solution.

I make sure to only run the deploy job if the trigger was a push to main branch with the conditional check if: github.ref == 'refs/heads/main'. We are setting up ruby version 3.3 and using gem install kamal pinned to a specific version.

When running kamal deploy, all we need is the KAMAL_REGISTRY_PASSWORD variable, which we are assinging the special value secrets.GITHUB_TOKEN that’s available in our build pipeline made available via github. This token already has the permission necessary to push to the github container registry. Go ahead and add the secret SSH_PRIVATE_KEY into your Github repository settings. This will used by kamal to ssh into the machine from Github’s build agent.

Conclusion

It took me a couple of tries to get to this stage. But, I’ve now got 3 side projects running on a single machine all deployed via kamal costing me just below £10 a month using Hetzner (excluding vms for database server).

I will write a follow up post if I use anymore capabilities of kamal. Or if I come across any difficulties. The only problem currently when using kamal for multiple application is that the traefik configuration has to be duplicated across multiple repositories. Well, it doesn’t have to be in all deploy.yml files. But, I’ve duplicated it.

Its very convinient to just run kamal traefik logs or kamal app logs -f to have visibility into traefik logs and application logs.