How to Self-Host Hugo with Docker

What Is Hugo?

Hugo is an open-source static site generator written in Go. It takes Markdown content and templates, and compiles them into plain HTML files in milliseconds. A site with thousands of pages builds in under a second. Unlike Ghost or WordPress, Hugo has no database, no server-side runtime, and no admin panel — you write Markdown files, run hugo, and deploy the output to any web server. The result is a site that loads instantly, costs almost nothing to host, and has virtually no attack surface. Official site

Hugo is not a typical Docker app. There is no persistent service to run. Instead, you use Docker in two ways: a development server with live reload for writing content, and a multi-stage build pipeline that compiles your site and serves it through Nginx in production.

Prerequisites

  • A Linux server (Ubuntu 22.04+ recommended)
  • Docker and Docker Compose installed (guide)
  • 512 MB of free RAM (for builds; the production Nginx container uses ~20 MB)
  • A Hugo site initialized locally or in a git repository
  • Basic familiarity with Markdown
  • A domain name (optional, for production deployment)

Docker Compose for Development

The development setup runs Hugo’s built-in server with live reload. Every time you save a file, the browser updates automatically.

Create a project directory:

mkdir -p ~/hugo-site && cd ~/hugo-site

Create a docker-compose.dev.yml file:

services:
  hugo:
    image: ghcr.io/gohugoio/hugo:v0.145.0
    container_name: hugo-dev
    command: server --bind 0.0.0.0 --baseURL http://localhost:1313 --appendPort=false --disableFastRender
    volumes:
      - ./:/project                  # Mount your Hugo project root
    ports:
      - "1313:1313"                  # Dev server with live reload
    working_dir: /project
    restart: unless-stopped

Start the development server:

docker compose -f docker-compose.dev.yml up -d

Open http://your-server-ip:1313 in your browser. Edit any Markdown file in content/ and the page reloads automatically.

Key flags explained:

  • --bind 0.0.0.0 — Listen on all interfaces, not just localhost (required inside Docker)
  • --disableFastRender — Rebuild the full page on changes, not just the changed section. Slower but avoids stale content bugs
  • --appendPort=false — Keeps URLs clean when accessed through a reverse proxy

If you do not have an existing Hugo site, create one first:

docker run --rm -v $(pwd):/project -w /project ghcr.io/gohugoio/hugo:v0.145.0 new site . --force

Production Deployment with Docker

Production uses a multi-stage approach: Hugo builds the static HTML, then Nginx serves it. This produces a tiny, secure container with no build tools or Go runtime — just Nginx and your HTML files.

Dockerfile

Create a Dockerfile in your Hugo project root:

# Stage 1: Build the site with Hugo
FROM ghcr.io/gohugoio/hugo:v0.145.0 AS builder

WORKDIR /site
COPY . .

# Download theme submodules if using git submodules
RUN if [ -f .gitmodules ]; then apk add --no-cache git && git submodule update --init --recursive; fi

# Build the site — output goes to /site/public/
RUN hugo --minify --gc

# Stage 2: Serve with Nginx
FROM nginx:1.28-alpine

# Remove default Nginx content
RUN rm -rf /usr/share/nginx/html/*

# Copy built site from Hugo stage
COPY --from=builder /site/public/ /usr/share/nginx/html/

# Copy custom Nginx config for clean URLs and caching
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget -qO /dev/null http://localhost/ || exit 1

Nginx Configuration

Create an nginx.conf file in your project root:

server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # Clean URLs — serve /about/ from /about/index.html
    location / {
        try_files $uri $uri/ $uri/index.html =404;
    }

    # Cache static assets aggressively
    location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # Gzip compression
    gzip on;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
    gzip_min_length 1000;

    # Custom 404 page (Hugo generates this if you create layouts/404.html)
    error_page 404 /404.html;
}

Docker Compose for Production

Create a docker-compose.yml file:

services:
  hugo-site:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: hugo-site
    ports:
      - "8080:80"                    # Web UI — map to any available host port
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "-qO", "/dev/null", "http://localhost/"]
      interval: 30s
      timeout: 3s
      start_period: 5s
      retries: 3

networks:
  default:
    name: hugo-network

Build and start:

docker compose up -d --build

Your site is now live at http://your-server-ip:8080. The entire container is roughly 15-25 MB depending on your content.

Rebuilding After Content Changes

Hugo is a build tool, not a live service. When you update content, rebuild the container:

docker compose up -d --build

Docker layer caching makes subsequent builds fast — typically under 10 seconds for content-only changes.

Configuration

Hugo uses a hugo.toml file (previously config.toml) in the project root. Here is a production-ready starting configuration:

baseURL = "https://example.com/"        # Your production domain — MUST include trailing slash
languageCode = "en-us"
title = "My Self-Hosted Site"
theme = "your-theme-name"               # Must match directory name in themes/

# Build settings
[build]
  writeStats = true                     # Enables PurgeCSS integration

[markup]
  [markup.goldmark]
    [markup.goldmark.renderer]
      unsafe = true                     # Allow raw HTML in Markdown (disable if untrusted authors)
  [markup.highlight]
    style = "monokai"                   # Syntax highlighting theme
    lineNos = false
    noClasses = false

[params]
  description = "Site description for SEO"
  author = "Your Name"

# Sitemap and RSS
[sitemap]
  changefreq = "weekly"
  filename = "sitemap.xml"
  priority = 0.5

[outputs]
  home = ["HTML", "RSS", "JSON"]        # JSON enables search integration

# Minification (also enabled via --minify flag)
[minify]
  disableCSS = false
  disableHTML = false
  disableJS = false
  disableSVG = false
  minifyOutput = true

# Permalink structure
[permalinks]
  posts = "/:year/:month/:slug/"
  pages = "/:slug/"

Key settings to change:

  • baseURL — Must match your production domain exactly, including the protocol and trailing slash. Wrong values break all internal links and RSS feeds.
  • theme — Must match a directory name in themes/. Without this, Hugo builds an empty site.
  • markup.goldmark.renderer.unsafe — Set to false if you accept content from untrusted contributors.

Themes

Hugo themes are installed as git submodules in the themes/ directory. This keeps theme code separate from your content and makes updates clean.

Installing a Theme

# From your Hugo project root
git submodule add https://github.com/theNewDynamic/gohugo-theme-ananke.git themes/ananke

Set the theme in hugo.toml:

theme = "ananke"
ThemeBest ForNotes
PaperModBlogs, documentationClean, fast, well-maintained
BlowfishPersonal sites, portfoliosTailwind CSS, dark mode
CongoDocumentation, content sitesFeature-rich, good SEO defaults
AnankeGetting startedOfficial starter theme
DocsyTechnical documentationGoogle-maintained, complex setup

Theme Customization

Override any theme template by creating the same file path in your project’s layouts/ directory. Hugo checks your project’s layouts/ before the theme’s.

# Override the theme's single post template
mkdir -p layouts/_default
cp themes/papermod/layouts/_default/single.html layouts/_default/single.html
# Edit layouts/_default/single.html with your changes

Hugo Extended for SCSS/SASS Themes

Some themes require Hugo Extended for SCSS/SASS processing. The official Docker image (ghcr.io/gohugoio/hugo) ships the Extended edition by default, so no changes are needed. If you use a community image, verify it includes the Extended build.

Content Management

Hugo content lives in the content/ directory as Markdown files. Each file has YAML front matter at the top.

Creating Content

# Using the Hugo CLI inside Docker
docker run --rm -v $(pwd):/project -w /project \
  ghcr.io/gohugoio/hugo:v0.145.0 new posts/my-first-post.md

This generates a file at content/posts/my-first-post.md:

---
title: "My First Post"
date: 2026-02-16T12:00:00Z
draft: true
---

Your content here. Standard Markdown with Hugo shortcodes.

Set draft: false when ready to publish. Hugo excludes drafts from production builds by default.

Content Organization

content/
├── _index.md              # Homepage content
├── about.md               # /about/ page
├── posts/
│   ├── _index.md          # Blog listing page
│   ├── first-post.md      # /posts/first-post/
│   └── second-post.md     # /posts/second-post/
└── docs/
    ├── _index.md          # Docs section listing
    └── getting-started.md # /docs/getting-started/

Directories under content/ become URL sections. Each _index.md controls the listing page for that section.

Page Bundles

For posts with images, use page bundles — a directory instead of a single file:

content/posts/my-post/
├── index.md               # The post content
├── hero.jpg               # Images co-located with the post
└── diagram.png

Reference images with relative paths in Markdown: ![Alt text](hero.jpg).

Automated Builds

Since Hugo requires a rebuild after every content change, automating that process is essential for a smooth workflow.

Webhook-Triggered Rebuilds

Use a lightweight webhook listener to trigger rebuilds when you push content changes to git.

Create a webhook/rebuild.sh script:

#!/bin/bash
set -euo pipefail

SITE_DIR="/opt/hugo-site"
LOG_FILE="/var/log/hugo-rebuild.log"

echo "$(date -u +'%Y-%m-%d %H:%M UTC') — Rebuild triggered" >> "$LOG_FILE"

cd "$SITE_DIR"
git pull origin main >> "$LOG_FILE" 2>&1
docker compose up -d --build >> "$LOG_FILE" 2>&1

echo "$(date -u +'%Y-%m-%d %H:%M UTC') — Rebuild complete" >> "$LOG_FILE"

Add a webhook service to your Docker Compose:

services:
  hugo-site:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: hugo-site
    ports:
      - "8080:80"
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "-qO", "/dev/null", "http://localhost/"]
      interval: 30s
      timeout: 3s
      start_period: 5s
      retries: 3

  webhook:
    image: almir/webhook:2.8.1
    container_name: hugo-webhook
    command: -hooks /etc/webhook/hooks.json -verbose
    volumes:
      - ./webhook:/etc/webhook           # Webhook configuration
      - /var/run/docker.sock:/var/run/docker.sock  # Access to rebuild containers
      - ./:/opt/hugo-site                # Hugo project directory
    ports:
      - "9000:9000"                      # Webhook listener port
    restart: unless-stopped

networks:
  default:
    name: hugo-network

Create webhook/hooks.json:

[
  {
    "id": "rebuild",
    "execute-command": "/etc/webhook/rebuild.sh",
    "command-working-directory": "/opt/hugo-site",
    "pass-arguments-to-command": [],
    "trigger-rule": {
      "match": {
        "type": "value",
        "value": "your-webhook-secret",
        "parameter": {
          "source": "header",
          "name": "X-Webhook-Secret"
        }
      }
    }
  }
]

Configure your git hosting provider (GitHub, Gitea, Forgejo) to send a POST request to http://your-server:9000/hooks/rebuild with the X-Webhook-Secret header on every push.

Cron-Based Rebuilds

If webhooks are not an option, use a simple cron job on the host:

# Rebuild every 15 minutes if the git repo has new commits
*/15 * * * * cd /opt/hugo-site && git fetch origin && [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ] && git pull origin main && docker compose up -d --build >> /var/log/hugo-rebuild.log 2>&1

Reverse Proxy

In production, put Hugo behind a reverse proxy for HTTPS. Since Hugo’s production container is just Nginx serving static files, the proxy configuration is straightforward.

Nginx Proxy Manager

Create a new Proxy Host:

  • Domain: yourdomain.com
  • Forward Hostname: hugo-site (container name)
  • Forward Port: 80
  • Enable SSL with Let’s Encrypt
  • Force SSL: Yes
  • HTTP/2: Yes

Make sure both containers share the same Docker network. Add the network to your Hugo docker-compose.yml:

networks:
  default:
    name: proxy-network
    external: true

For a full reverse proxy setup, see Reverse Proxy Explained.

Caddy

If you use Caddy as your reverse proxy, add to your Caddyfile:

yourdomain.com {
    reverse_proxy hugo-site:80
    encode gzip
}

Caddy handles HTTPS certificates automatically.

Backup

Hugo sites are inherently easy to back up because everything is files — no database dumps required.

What to Back Up

PathContentsPriority
content/All Markdown posts and pagesCritical — this is your content
hugo.tomlSite configurationCritical
static/Images, favicons, custom CSS/JSCritical
layouts/Template overridesImportant
themes/Theme (tracked as git submodule)Low — can re-clone
public/Built HTML outputSkip — regenerated on build

Backup Strategy

If your Hugo site is in a git repository (it should be), your content is already versioned and backed up on your remote. For belt-and-suspenders protection:

# Back up the entire project directory (excluding build output)
tar --exclude='public' --exclude='.git' -czf hugo-backup-$(date +%Y%m%d).tar.gz /opt/hugo-site/

For a comprehensive approach, see Backup Strategy — The 3-2-1 Rule.

Troubleshooting

Hugo Build Fails with “TOCSS: failed to transform”

Symptom: Build error mentioning SCSS/SASS transformation failure.

Fix: Your theme requires Hugo Extended. The official Docker image (ghcr.io/gohugoio/hugo) includes Extended by default. If you are using a community image, switch to the official one or verify the image includes the Extended edition:

docker run --rm ghcr.io/gohugoio/hugo:v0.145.0 version
# Should show "extended" in the output

Site Builds But Pages Are Blank

Symptom: Hugo builds without errors, but the site shows empty pages or a blank homepage.

Fix: This almost always means the theme is not loaded correctly. Check:

  1. The theme value in hugo.toml matches the directory name in themes/
  2. The theme submodule was initialized: git submodule update --init --recursive
  3. In Docker, the .gitmodules file and themes/ directory are properly mounted or copied

Live Reload Not Working in Docker

Symptom: The dev server runs, but changes to content do not trigger a browser reload.

Fix: Hugo’s file watcher can miss changes when files are mounted from the host via Docker volumes on certain filesystems. Add the --poll flag:

command: server --bind 0.0.0.0 --baseURL http://localhost:1313 --appendPort=false --disableFastRender --poll 500ms

The --poll 500ms flag forces Hugo to poll for changes every 500 milliseconds instead of relying on filesystem events.

Symptom: Links, CSS, and JavaScript paths point to localhost or the wrong domain in production.

Fix: The baseURL in hugo.toml must exactly match your production URL, including the protocol and trailing slash. If you use different URLs for development and production, override it at build time:

hugo --minify --gc --baseURL https://yourdomain.com/

Docker Build Fails on Git Submodules

Symptom: The Dockerfile fails at the git submodule update step.

Fix: The official Hugo image is Alpine-based and does not include git by default. The Dockerfile provided above installs it conditionally. If you see errors about git not found, ensure this line exists in your Dockerfile’s build stage:

RUN if [ -f .gitmodules ]; then apk add --no-cache git && git submodule update --init --recursive; fi

Also make sure your .gitmodules file and .git directory are not excluded by .dockerignore.

Resource Requirements

Hugo is remarkably lightweight:

  • Build (Hugo container): 256-512 MB RAM during builds, negligible CPU time (builds thousands of pages in <1 second). The build container exits after generating HTML.
  • Production (Nginx container): ~20 MB RAM idle. Handles thousands of concurrent requests with minimal CPU.
  • Disk: The Hugo Docker image is ~80 MB. The Nginx Alpine image is ~40 MB. Your built site is typically 5-50 MB depending on images.
  • CPU: Trivial. Hugo builds are CPU-bound but finish in milliseconds to seconds. Nginx serving static files barely registers on a CPU monitor.

Hugo is one of the least resource-intensive options for running a website. A single VPS can host dozens of Hugo sites behind one Nginx instance.

Verdict

Hugo is the best option for developers and technical users who want a fast, secure, low-maintenance website. If you are comfortable writing Markdown and can run docker compose up, Hugo gives you a site that loads in under 100ms, costs next to nothing to serve, and has zero security vulnerabilities from server-side code.

Choose Hugo over Ghost if you do not need a web-based editor, memberships, or newsletters. Ghost is a full CMS with a database — powerful but heavier. Hugo is a build tool that produces plain HTML — simpler and faster.

Choose Hugo over WordPress if you want speed, security, and simplicity above all else. WordPress has a massive plugin ecosystem and visual editing, but it requires PHP, MySQL, and constant security patching. Hugo has none of that overhead.

Choose Ghost or WordPress over Hugo if non-technical people need to edit content through a web browser, or if you need built-in membership and payment features.

For most personal blogs, documentation sites, and project pages maintained by a developer, Hugo is the right call.