This blog is built with Hugo using the PaperMod theme and is automatically built, published, and deployed through a fully automated CI/CD pipeline powered by GitHub Actions, Docker, GitHub Container Registry (GHCR), Watchtower, and Pangolin.
Hugo is an open-source static site generator written in Go. Instead of generating pages dynamically whenever a visitor opens a website, Hugo generates the complete website in advance.
Content is typically written in Markdown and combined with configuration, templates, themes, and static assets to generate the final website. Hugo is designed to be fast, flexible, and suitable for anything from personal blogs and documentation sites to larger websites.
Because Hugo generates static files, websites built with Hugo do not require a database or application backend. This makes them relatively simple to host, maintain, and deploy.
The goal is simple:
Every git push results in an updated blog without manually logging in to the docker host.
Overview
| # | Step | Description | Tool |
|---|---|---|---|
| 1 | Develop | Develop the blog | Zed / Hugo / Papermod |
| 2 | Commit | Commit changes to Git | Zed / Git |
| 3 | Push | Push changes to GitHub | Zed / Github |
| 4 | Trigger | Workflow execution trigger | Github Actions |
| 5 | Build | Docker image creation | Github Actions |
| 6 | Publish | Publish docker image to GitHub Container Registry | Github Actions / GHCR |
| 7 | Deploy | Auto pull image to docker host and restart container | Watchtower / Docker compose |
| 8 | Serve | Route incoming traffic to updated container | Pangolin |
Zed – Primary code editor used to develop and maintain the blog.
Hugo – Static site generator used to build the blog.
PaperMod – Hugo theme used for the blog.
GitHub – Hosts the source code.
GitHub Actions – Builds and publishes the Docker image.
GitHub Container Registry (GHCR) – Stores the Docker image.
Docker – Packages the blog into a container.
Docker Compose – Defines and manages the blog container on the docker host using the latest Docker image published to GitHub Container Registry (GHCR).
Watchtower – Automatically updates the running container when a new image is published.
Pangolin – Secure reverse proxy exposing the service to the internet.
1. Develop
The development stage takes place locally using Zed, Hugo, and the Papermod theme. During this stage, website content, configuration, and layouts can be created and modified before changes are committed to Git.
Local development
For local development and previewing, the Hugo development server can be started with:
hugo server -D
Hugo builds the website and starts a local development server. Changes to content or configuration are automatically detected and the website is rebuilt, making it possible to immediately preview changes in the browser.
The -D option includes content marked as a draft, allowing unpublished posts to be previewed during development.
By default, the website is available at:
http://localhost:1313
If port 1313 is already in use, Hugo may automatically select another available port.
Docker configuration
The repository also contains the files required to build the production Docker image:
dockerfile— Contains the instructions for building the production Docker image. Hugo first generates the static website, after which the generated files are copied into an Nginx image that is used to serve the website..dockerignore— Defines which files and directories should be excluded from the Docker build context. This prevents unnecessary files, such as the Git repository metadata and locally generated Hugo output, from being included in the Docker build.
The same Docker configuration that is later used by the CI/CD pipeline can also be built and tested locally.
Build the Docker image
The production Docker image can be built locally with:
docker build -t s91-blog .
The docker build command creates the image using the Dockerfile. The -t s91-blog option assigns the name s91-blog to the locally built image, while the final . specifies the current directory as the Docker build context.
Test the Docker image
After the image has been built, a temporary container can be started with:
docker run --rm -p 8080:80 s91-blog
The -p 8080:80 option maps port 8080 on the local machine to port 80 inside the Nginx container.
The --rm option automatically removes the container after it is stopped.
The containerized production version of the website can then be accessed at:
http://localhost:8080
Testing both the Hugo development server and the Docker image locally provides two different ways to validate the website. The Hugo development server is useful for quickly previewing changes while developing, while the Docker container verifies that the production build works correctly before the changes enter the CI/CD pipeline.
2. Commit
Once the changes are ready, they are committed to Git. A commit captures a snapshot of the website at a specific point in time, making it easy to track changes, review the project history, and roll back to an earlier version if needed. This keeps the project versioned and provides a clear history of changes.
3. Push
After the changes have been committed, they are pushed to GitHub. GitHub acts as the central source repository for the blog and serves as the trigger point for the automated CI/CD pipeline. From this point onward, the remaining build and deployment process happens automatically.
4. Trigger
A push to the main branch triggers the GitHub Actions workflow. This is the start of the CI/CD pipeline. Instead of manually building or deploying anything, GitHub detects the update and starts the automation.
5. Build
When the workflow starts, GitHub Actions provisions a temporary Ubuntu runner in the GitHub infrastructure. This is a clean, short-lived virtual machine that is created specifically to execute the workflow.
The repository is downloaded to the runner, where Docker Buildx is prepared to build the Docker image. Buildx is Docker’s build tool and uses the dockerfile from the repository as the instructions for creating the image.
The Docker image is created using a multi-stage build, which separates the build environment from the final production environment.
In the first stage, a Hugo image is used as the build environment. Hugo reads the markdown content, configuration, papermod theme, images, and other source files and generates the complete static website. The result is stored in the “public/” directory as static files.
In the second stage, a clean nginx image is created. Only the generated “public/” directory is copied from the Hugo build stage into the Nginx image. Hugo, source files, and the build tools are therefore not included in the final production image.
This results in a smaller and cleaner Docker image that only contains nginx and the generated website files needed to serve the website.
The result is a lightweight production docker image containing nginx and the generated static website. Hugo, the source files, and the other build dependencies are not included in the final image.
Once the workflow has completed, the temporary Ubuntu runner is automatically destroyed.
6. Publish
After the Docker image has been built successfully, GitHub Actions publishes it to GitHub Container Registry (GHCR).
Each image is published with two tags:
latest— Always points to the most recently published version and is used by the production environment.sha-<commit>— Identifies the image associated with a specific Git commit, making it possible to trace an image back to its source code or roll back to an earlier version.
For example:
ghcr.io/s91nl/blog:latest
ghcr.io/s91nl/blog:sha-a1b2c3d
The latest tag is referenced by the Docker Compose configuration on the production server. When a new image is published with this tag, Watchtower detects the change and automatically starts the deployment process.
The commit-specific tag remains available in GHCR and can be used to identify or manually deploy a previous version if a rollback is required.
7. Deploy
The production environment runs on an Ubuntu Server using Docker Compose. Docker Compose defines how the blog and its dedicated Watchtower instance are configured and managed.
The blog uses the latest image published to GitHub Container Registry (GHCR).
ghcr.io/s91nl/blog:latest
The production stack is defined using the following compose.yaml:
services:
blog:
image: ghcr.io/s91nl/blog:latest
container_name: blog
restart: unless-stopped
ports:
- "8001:80"
labels:
- com.centurylinklabs.watchtower.enable=true
watchtower:
image: nickfedor/watchtower:latest
container_name: blog-watchtower
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
command:
- --label-enable
- --interval
- "900"
- --cleanup
Blog container
The blog service runs the latest Docker image published by the CI/CD pipeline to GHCR.
The following port mapping exposes port 80 of the Nginx container through port 8001 of the Docker host:
Docker host:8001 → blog container:80
This is configured in Docker Compose as:
ports:
- "8001:80"
The blog can therefore be reached internally through port 8001 of the Ubuntu Server. Pangolin uses this endpoint to route incoming traffic to the blog.
The following label enables automatic updates for the blog container:
com.centurylinklabs.watchtower.enable=true
Watchtower container
A dedicated Watchtower instance is used to automatically keep the blog container up to date.
The --label-enable option ensures that this Watchtower instance only monitors containers that explicitly have the Watchtower enable label. In this stack, only the blog container has this label.
command:
- --label-enable
- --interval
- "900"
- --cleanup
The --interval 900 option instructs Watchtower to check for a new image every 15 minutes.
When GitHub Actions publishes a new version of ghcr.io/s91nl/blog:latest, Watchtower detects that the image has changed. It then pulls the new image and automatically recreates the blog container using the updated version.
The --cleanup option removes the previous Docker image after a successful update.
The automated deployment flow is therefore:
New image published to GHCR
↓
Watchtower detects the update
↓
Pull latest image
↓
Recreate blog container
↓
Updated website is running
Other containers on the Ubuntu Server are not automatically updated by this dedicated Watchtower instance. Updates to the rest of the Docker environment can still be performed separately using a manually triggered Watchtower run.
8. Serve
Finally, Pangolin routes incoming traffic to the updated container. It acts as the public-facing entry point and securely exposes the blog to the internet. From the visitor’s point of view, the website simply stays online and up to date.