Deploy Hugo static site to Hetzner

📅 Wed, Sep 3, 2025 ⏱️ 4-minute read

Recently I ditched my AWS account and moved my personal small projects to a Hetzner VPS, what a nice feeling tinkering with your own server again! As a part of this process I also had to change how I deploy this blog, so I thought I would share my setup in case anyone else is interested.

This blog uses Hugo as a static site generator, so my server only needs to serve static files. I chose to use Caddy as my web server because it is easy to set up and automatically manages HTTPS certificates via Let’s Encrypt.

Install Caddy

First, let’s set up our Hetzner VPS with all the necessary components.

Install it using the official installation script:

1
2
3
4
5
6
7
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
chmod o+r /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy

Keep Caddy running

There are numerous advantages to using a service manager to keep it running, such as ensuring it starts automatically when the system reboots and to capture stdout/stderr logs.

1
2
3
sudo systemctl daemon-reload
sudo systemctl enable --now caddy
systemctl status caddy

Prepare static file directory

We’ll come back to this part later when we set up deployment, but for now, create the directory where your Hugo site will be served from. I’ve found it most convenient to put the static site directory inside the directory /var/www. In my case it’s /var/www/blog, it seems to be a common location, so there’s a decent chance it will be familiar to other developers. Make sure Caddy has permission to read from this directory.

1
2
3
sudo mkdir -p /var/www/blog
sudo chown -R caddy:caddy /var/www/blog
sudo chmod -R 755 /var/www/blog

Configure Caddy

It’s time to write a Caddyfile, which will tell Caddy how to serve our static site.

1
sudo vim /etc/caddy/Caddyfile

The configuration is quite simple in my case:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
:80 {
    root * /var/www/blog
    encode gzip
    file_server

    log {
        output file /var/log/caddy/blog.log
    }

    header {
        ?Cache-Control "max-age=3600"
    }
}

Use systemctl reload caddy to tell Caddy to use the new version. Your static site should appear at http://your-server-ip once you deploy it.

HTTPS

Caddy will automatically manage HTTPS certificates for you, but you need to have a domain name pointing to your server’s IP address. Once you have that set up, modify your Caddyfile to use your domain name:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
your-domain.com {
    root * /var/www/blog
    encode gzip
    file_server

    log {
        output file /var/log/caddy/blog.log
    }

    header {
        ?Cache-Control "max-age=3600"
    }
}

Tell Caddy to use the new version of the file by running systemctl reload caddy.

Wait a few moments and check the runtime logs using systemctl status caddy. Hopefully you’ll see messages about Caddy sucessfully getting a TLS certificate.

Now you should be able to access your site securely at https://your-domain.com

Manual Deployment

You can deploy manually using rsync from your local machine:

1
2
3
hugo --environment production

rsync -avz --delete ./public/ user@your-server:/var/www/blog/

The --delete flag removes files on the server that no longer exist in your local build, keeping your deployment clean.

GitHub Actions Automation

Obviously, doing this manually is tedious and error-prone. For automated deployments, I use GitHub Actions with SSH and rsync. Here’s my workflow configuration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
name: Build and Deploy

on:
  push:
    branches:
      - master

jobs:
  build:
    name: Build and Deploy
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1

      - name: Install Hugo
        run: |
          HUGO_DOWNLOAD=hugo_extended_withdeploy_${HUGO_VERSION}_Linux-64bit.tar.gz
          wget https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/${HUGO_DOWNLOAD}
          tar xvzf ${HUGO_DOWNLOAD} hugo
          mv hugo $HOME/hugo
        env:
          HUGO_VERSION: 0.143.1

      - name: Hugo Build
        run: $HOME/hugo --environment production

      - name: Install SSH Key
        uses: shimataro/ssh-key-action@v2
        with:
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          known_hosts: 'placeholder'

      - name: Adding Known Hosts
        run: ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts

      - name: Deploy with rsync
        run: rsync -avz ./public/ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/blog/

Set up these secrets in your GitHub repository:

  • SSH_PRIVATE_KEY: Your private SSH key for server access
  • SSH_HOST: Your server’s IP address or domain
  • SSH_USER: Your server username

SSH Key Setup

Generate an SSH key pair for deployment:

1
ssh-keygen -t rsa -b 4096 -C "deployment@your-domain.com"

Then copy the public key to your server:

1
ssh-copy-id -i ~/.ssh/id_rsa.pub user@your-server

Benefits of This Setup

  1. Automatic HTTPS: Caddy handles SSL certificates automatically
  2. Fast deployments: rsync only transfers changed files
  3. Version control: All changes are tracked in git
  4. Cost effective: Much cheaper than managed hosting services
  5. Full control: Complete control over your hosting environment

Troubleshooting

Check Caddy Status

1
2
sudo systemctl status caddy
sudo journalctl -u caddy -f

Verify File Permissions

1
ls -la /var/www/blog/

Feedback

As always, please reach out to me on X with questions, corrections, or ideas!