Multi-Arch CI/CD: Building a Hugo Nginx Container

This write-up covers the transition from local Hugo builds to an automated, multi-architecture (AMD64/ARM64) Docker pipeline using GitHub Actions and Docker Hub.

The Problem

I wanted to simplify my website development. Rather than focusing on infrastructure, I wanted to focus on content. I chose Hugo because it allows me to create simple Markdown files while generating an elegant, high-performance site.

However, traditional deployment often involves manual rsyncing or running Hugo directly on a production server. This creates “it works on my machine” issues and makes architecture-specific deployments (like moving to an ARM64 server) a logistical headache.

The Solution: Immutable Containers

The solution is to package the entire site into a standard Nginx Docker image. We chose Nginx over the built-in hugo server because Nginx is a battle-hardened production web server, whereas hugo server is intended for local development. By using a containerized approach, we ensure the environment is identical from development to production.

Create the Site

After installing Hugo, the setup is straightforward:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Create the new site
hugo new site nibblesnbits
cd nibblesnbits

# Initialize git (required for themes as submodules)
git init

# Add the PaperMod theme
git submodule add --depth=1 https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod

# Tell Hugo to use the theme
echo "theme = 'PaperMod'" >> hugo.toml

1. The Nginx Configuration

To handle Hugo’s “Pretty URLs” (e.g., /posts/my-post/ instead of /posts/my-post.html), we need a custom Nginx configuration. Nginx needs to be told to look for an index.html within a directory if the direct file path isn’t found.

File: default.conf

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
server {
    listen 80;
    server_name localhost;

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
        try_files $uri $uri/ =404;
    }
}

2. The Dockerfile

Our Dockerfile follows a “slim” pattern. We build the static files outside of Docker in our CI/CD runner and then COPY them in. This keeps our final production image extremely small and secure.

File: Dockerfile

1
2
3
4
5
6
FROM nginx:alpine
# Copy our static site files from the Hugo build
COPY public/ /usr/share/nginx/html
# Apply our custom Nginx logic
COPY default.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

3. The GitHub Actions Pipeline

This is the core of the automation. We use QEMU to emulate different CPU architectures, allowing us to build an ARM64 image (for our server) even though GitHub’s runners use AMD64.

File: .github/workflows/deploy.yml

 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
38
39
40
41
42
43
44
name: Build and Push

on:
  push:
    branches: [ "main" ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive

      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v2
        with:
          hugo-version: 'latest'
          extended: true

      - name: Build Hugo
        # We build the site BEFORE the Docker step to keep the image slim
        run: hugo --minify --baseURL="https://nibblesnbits.org/"

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Build and Push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          # Supporting both architectures in one manifest
          platforms: linux/amd64,linux/arm64
          tags: ${{ secrets.DOCKERHUB_USERNAME }}/nibblesnbits:latest

4. Automated Deployment with Watchtower

Instead of GitHub “pushing” to our server, we use a “pull” model via Watchtower. Watchtower runs on our production server and polls Docker Hub for updates. This is more secure because it doesn’t require us to open SSH ports to the public internet for our CI/CD to function.

File: docker-compose.yml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
services:
  blog:
    image: yourdockerusername/nibblesnbits:latest
    container_name: nibblesnbits_prod
    ports:
      - "80:80"
    restart: unless-stopped

  watchtower:
    image: containrrr/watchtower
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ~/.docker/config.json:/config.json
    command: --interval 300 --cleanup

Summary

With this architecture, the workflow is now fully automated and architecture-agnostic. We focus on content, and the infrastructure handles the rest.