In this post I’m going to deploy a static website with kamal-skiff, a Hetzner Cloud VM and Cloudflare. I’m going to use GitHub and its private container registry to upload docker images.

For this post let’s pick <username> and <demo> as a sample GitHub username and the repository name. If you want to follow this guide, you need to replace those values with your own username and repository name.

Basic development setup

To begin with development, I’m creating a private GitHub repository called <demo>.

After running gem install kamal-stiff, I’m running

$ skiff new demo && cd demo

to setup the project.

Now let’s commit push the generated files to the private <demo> repository:

$ git init .
$ git add .
$ git commit -m 'initial commit'
$ git remote add origin<YOUR GITHUB USERNAME>/<demo>.git
$ git push -u origin main

Run the demo site with skiff dev and open localhost:4000. You can update the index.html and see your changes after a browser reload.

Setting up

Instead of using Docker Hub as an image repository, I’m configuring the setup to use the GitHub container repository. Therefore I need to change the deploy.yml and point it to the GitHub container repository by replacing the whole existing registry: configuration with:

  username: <username>

Now I’m generating the GITHUB_REGISTRY_TOKEN token. If you’re logged into GitHub you can reach it at For reading and writing packages to we need to create a “classic” token.

To create a classic token with minimal permissions you actually need to visit this magic link, because just clicking the packages scopes will select more permissions than theoretically required. Check out the documentation to see a detailed explanation on how to create such a token and how to login the registry.

After creating the token, add it to .env with GITHUB_REGISTRY_TOKEN=xxx_your_token.

Before pushing a first image to, I’m adding a label to our Dockerfile to connect the pushed image with our new private repostitory:

FROM nginx:latest
LABEL org.opencontainers.image.source="<username>/<demo>"

You can test your setup by building and pushing an image to the repository with:

$ docker build . -t<username>/<demo>:latest
$ docker push<username>/<demo>:latest

If everything worked correctly, you should see a new package under packages correctly connected with your private git repository.

Creating the Hetzner VM

In this step I’ll create a Hetzner Cloud VM to run our container image.

After logging in to Hetzner Cloud, I’ll create a new Project called <demo>.

I recommed to create a new ed25519 SSH-Keypair to connect to your Hetzner VMs, follow this link for an explanation on how to do so.

As a next step, I’ll add a new server to the new <demo> Hetzner cloud project. Click on “Add server” and configure a server with:

  • Image Ubuntu 22.04
  • Shared vCPU with ARM64, e.g. CAX11
  • Public IPv4 address
  • Add your newly generated SSH Public Key

Replace the deploy.yml servers: default ip with your actual public Hetzner VM IP that was assigned to your VM.

After the server has been initialized, you should be able to ssh into it with ssh root@<your server ip>.

If you can connect, you can continue with the next step.

Deploying with kamal-skiff

Before you can deploy your site, you need to create an access token to clone your GitHub repository. Skiff uses this token to pull the newest changes in the Git repository and updates the deployed site.

I’m using a “fine grained” token, with minimal permissions to access the <demo> repository. Go to and select:

  • Repository access to “Only select repositories” and select <demo>
  • Permissions “Contents” with “Access: Read-only”

Click on “Generate token” and add the following line to the .env file:

GIT_URL=https://<username>:<your generated token><username>/<demo>.git

You can run skiff deploy and watch the site being deployed on your cloud VM. If everything works fine, you should see your site with curl <your server ip>.

Adding a domain name and SSL with Cloudflare

I’m going to use Cloudflare to set up DNS and SSL for the freshly deployed website.

Go to the Cloudflare dashboard and register a domain name under Domain Registration. In the next step you need to configure a DNS A-Record. Click on Websites and click on your bought domain name. Now go to DNS -> Records and create an A-Record with the name and your cloud vm’s ipv4 address. Keep the Proxy status to “proxied”.

After a few minutes, your site is reachable via your configured domain name.

If you check the SSL/TLS config, you’ll see that the SSL encryption mode is set to flexible. As the illustration shows, your traffic is encrypted between the browser and the cloudflare proxy, which is the reason the browser shows a green lock and no SSL warning. Congrats! 🎉

Additional thoughts

I spent some time in the past weeks playing around with Hetzner and Kamal and I’m actually very impressed by the simplicity and performance. I think kamal-skiff is a good introduction to Kamal so I encourage you to give it a try as well. I think a similar tool to manage the maintenance and base configuration of VM tasks would be great. E.g. for setting up a secure base image, manage backups, harden SSH configs etc.


  • It would be great if GitHub would allow more fine-grained permissions for the container registry access tokens.
  • You should harden your SSH setup on your VM, configure a strict firewall and configure unattended upgrades.
  • You should probably use a Cloudflare SSL setup that encrypts end to end instead of flexible.

Please reach out on X if you have suggestions or send me an email.