Deploying a Hugo Site with GitHub Actions, Docker Swarm, and Cloudflare
Table of Contents
I recently set up my personal website with a fully automated deployment pipeline. Every time I push to the master branch, my site automatically builds, containerizes, and deploys to my VPS. Here’s how I did it.
The Stack#
- Hugo — Static site generator
- Docker — Containerization
- GitHub Actions — CI/CD pipeline
- GHCR — Container registry
- Docker Swarm — Orchestration on the VPS
- Nginx — Reverse proxy
- Cloudflare — DNS and SSL
Setting Up the Repository#
First, I initialized a local Git repository and connected it to GitHub via SSH:
1git init
2git remote add origin [email protected]:username/mysite.git
If you haven’t set up SSH keys with GitHub, you’ll need to generate one and add it to your account:
1ssh-keygen -t ed25519 -C "[email protected]"
2cat ~/.ssh/id_ed25519.pub
3# Add this to GitHub → Settings → SSH and GPG keys
The Dockerfile#
Hugo builds static files, so the container just needs to build the site and serve it with nginx:
1FROM hugomods/hugo:exts as builder
2
3WORKDIR /src
4COPY . .
5RUN hugo --minify
6
7FROM nginx:alpine
8RUN rm -rf /usr/share/nginx/html/*
9COPY --from=builder /src/public /usr/share/nginx/html
10EXPOSE 80
The rm -rf line is important — it clears nginx’s default welcome page before copying your Hugo output.
If you’re using a theme as a Git submodule, it won’t be included in the Docker build automatically. The solution is to handle it in the GitHub Actions workflow instead.
Docker Compose for Swarm#
1services:
2 web:
3 image: ghcr.io/username/mysite:latest
4 ports:
5 - "81:80"
6 deploy:
7 replicas: 1
8 update_config:
9 order: start-first
I’m using port 81 here because port 80 is already used by my nginx reverse proxy.
GitHub Actions Workflow#
The workflow does three things: builds the Hugo site, pushes the image to GHCR, and deploys to my VPS via SSH.
1name: Build and Deploy
2
3on:
4 push:
5 branches: [master]
6
7env:
8 IMAGE: ghcr.io/${{ github.repository }}
9
10jobs:
11 build:
12 runs-on: ubuntu-latest
13 permissions:
14 contents: read
15 packages: write
16 steps:
17 - uses: actions/checkout@v4
18 with:
19 submodules: true
20
21 - name: Log in to GHCR
22 run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
23
24 - name: Build and push
25 run: |
26 docker build -t $IMAGE:latest -t $IMAGE:${{ github.sha }} .
27 docker push $IMAGE --all-tags
28
29 deploy:
30 needs: build
31 runs-on: ubuntu-latest
32 steps:
33 - uses: actions/checkout@v4
34
35 - name: Setup SSH
36 run: |
37 mkdir -p ~/.ssh
38 echo "${{ secrets.VPS_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
39 chmod 600 ~/.ssh/id_ed25519
40 ssh-keyscan -H ${{ secrets.VPS_IP }} >> ~/.ssh/known_hosts
41
42 - name: Deploy stack
43 run: |
44 scp compose.yml ${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}:/tmp/mysite-compose.yml
45 ssh ${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }} << 'EOF'
46 docker pull ghcr.io/username/mysite:latest
47 docker stack deploy -c /tmp/mysite-compose.yml mysite
48 rm /tmp/mysite-compose.yml
49 EOF
The submodules: true option ensures your Hugo theme gets checked out if it’s a submodule.
Required Secrets#
Add these in your GitHub repo under Settings → Secrets and variables → Actions:
VPS_IP— Your server’s IP addressVPS_USER— SSH usernameVPS_PRIVATE_KEY— Your private SSH key (include the trailing newline)
VPS Authentication to GHCR#
Your VPS needs to pull the image from GHCR. Since images are private by default, authenticate once on your server:
1echo "ghp_yourtoken" | docker login ghcr.io -u username --password-stdin
Create a Personal Access Token with read:packages scope in GitHub → Settings → Developer settings → Personal access tokens.
Nginx Reverse Proxy#
My VPS runs a dockerized nginx reverse proxy. Here’s the configuration for routing traffic to the Hugo container:
1server {
2 listen 80;
3 server_name mysite.com www.mysite.com;
4 return 301 https://mysite.com$request_uri;
5}
6
7server {
8 listen 443 ssl;
9 server_name www.mysite.com;
10
11 ssl_certificate /etc/nginx/ssl/mysite.com.pem;
12 ssl_certificate_key /etc/nginx/ssl/mysite.com.key;
13
14 return 301 https://mysite.com$request_uri;
15}
16
17server {
18 listen 443 ssl;
19 server_name mysite.com;
20
21 ssl_certificate /etc/nginx/ssl/mysite.com.pem;
22 ssl_certificate_key /etc/nginx/ssl/mysite.com.key;
23
24 location / {
25 proxy_pass http://host.docker.internal:81;
26 proxy_set_header Host $host;
27 proxy_set_header X-Real-IP $remote_addr;
28 proxy_redirect http:// https://;
29 }
30}
The proxy_redirect http:// https://; line is important. The Hugo container only speaks HTTP internally, so when nginx inside it issues a redirect — for example, to add a trailing slash to a URL — it produces an http:// location header. Without this directive, that leaks out to the client, causing a two-hop redirect chain: the client first follows the http:// URL, then gets bounced to https:// by the first server block. This is what triggers redirect errors in Google Search Console.
Since the reverse proxy runs in Docker, it can’t reach localhost on the host. The host.docker.internal hostname solves this — add it to your reverse proxy’s compose file:
1services:
2 nginx:
3 image: nginx:latest
4 ports:
5 - "80:80"
6 - "443:443"
7 extra_hosts:
8 - "host.docker.internal:host-gateway"
9 volumes:
10 - ./nginx.conf:/etc/nginx/nginx.conf:ro
11 - ./conf.d:/etc/nginx/conf.d:ro
12 - ./ssl:/etc/nginx/ssl:ro
13 restart: unless-stopped
Cloudflare Setup#
DNS Records#
Add an A record pointing to your VPS:
- Type: A
- Name:
@ - IPv4 address: Your VPS IP
- Proxy status: Proxied (orange cloud)
For www redirect support, add a CNAME:
- Type: CNAME
- Name:
www - Target:
mysite.com - Proxy status: Proxied
SSL Certificates#
Cloudflare Origin Certificates provide free SSL between Cloudflare and your server:
- Go to SSL/TLS → Origin Server → Create Certificate
- Add both
mysite.comand*.mysite.comfor a wildcard cert - Save the certificate and private key to your VPS
- Set SSL/TLS mode to Full (strict)
A wildcard certificate covers your root domain and all subdomains, so you only need one cert for everything.
Troubleshooting#
“Welcome to nginx” instead of your site#
Hugo isn’t generating an index.html, or nginx’s default page is overwriting it. Make sure:
- Your content pages have
draft: falsein the front matter - You have a
content/_index.mdfor the homepage - The Dockerfile clears nginx’s default files before copying
CSS not loading#
Check your baseURL in Hugo config — it should match your actual domain:
1baseURL = "https://mysite.com/"
Container can’t reach host services#
Use host.docker.internal instead of localhost and add the extra_hosts mapping to your compose file.
Redirect errors in Google Search Console#
If Search Console reports redirect errors, the likely cause is the Hugo container issuing http:// location headers for trailing-slash redirects. The proxy_redirect http:// https://; directive in the reverse proxy rewrites those to https:// before they reach the client, collapsing the redirect chain.
The Result#
Now every git push to master triggers a full deployment:
- GitHub Actions builds the Hugo site
- Creates a Docker image and pushes to GHCR
- SSHs into the VPS
- Pulls the new image and updates the Swarm stack
The whole process takes about a minute. No manual deployment steps, no FTP uploads, no SSH-ing in to pull changes. Just push and it’s live.