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 address
  • VPS_USER — SSH username
  • VPS_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:

  1. Go to SSL/TLS → Origin Server → Create Certificate
  2. Add both mysite.com and *.mysite.com for a wildcard cert
  3. Save the certificate and private key to your VPS
  4. 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:

  1. Your content pages have draft: false in the front matter
  2. You have a content/_index.md for the homepage
  3. 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:

  1. GitHub Actions builds the Hugo site
  2. Creates a Docker image and pushes to GHCR
  3. SSHs into the VPS
  4. 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.