Setting Up Hugo with Docker

I rented a cloud machine and put a blog on it: Hugo to build the site, Docker to run it, Caddy in front for HTTPS. Notes from that setup.

  • Container Service Setup: define Hugo in docker-compose with the klakegg/hugo image. Mount the source at /src and logs at /tmp/logs/. Expose port 3000 for local access.

  • Port Mapping (Host:Container): 127.0.0.1:3000:3000 publishes container port 3000 on host loopback. Host port comes first, container port second. Binding to 127.0.0.1 keeps the dev server off the public interface, which matters because Caddy handles everything that faces the internet.

  • File Mapping (Volumes): ./hugo:/src maps the whole project into the container, so an edit on the host is live in the container with no rebuild. ./data/hugo/logs/:/tmp/logs/ keeps the logs on the host, where they survive the container.

hugo:
  image: klakegg/hugo:latest
  container_name: hugo
  restart: unless-stopped
  ports:
    - "127.0.0.1:3000:3000"
  volumes:
    - ./hugo:/src
    - ./data/hugo/logs/:/tmp/logs/
  command: server --bind="0.0.0.0" --port=3000 --debug --log --verboseLog --verbose --buildDrafts --buildFuture --baseUrl="https://www.example.com" --appendPort=false --logFile=/tmp/logs/hugo.log --config=config.toml --configDir=/src/ --source=/src/ --themesDir=/src/themes/
  environment:
    USER: user
  • Essential Flags: --debug --log --verboseLog --verbose for debugging. --buildDrafts --buildFuture to render posts Hugo would otherwise skip. Set base URL, config path, and theme directory by hand, because the defaults assume a layout this one does not have.

  • Log Mounting: write logs to /tmp/logs/hugo.log inside the container, which lands in ./data/hugo/logs/ on the host and outlives a restart.

  • Inspecting Logs: follow them with docker-compose logs -f hugo, read the history with cat ./data/hugo/logs/hugo.log, and find failures with grep -i error ./data/hugo/logs/hugo.log.

  • Debugging Build Errors: turn on every verbose flag and Hugo names the file it choked on. Most of my breakages came down to a theme missing from ./hugo/themes/ or bad TOML, which hugo config catches.

  • Verifying Functionality: load http://127.0.0.1:3000, or curl it from the host. Navigate to /blog and check the post dates, since a wrong --buildFuture hides your newest post.

  • Theme Setup: clone the theme into ./hugo/themes/, name it in config.toml, and point the container at it with --themesDir=/src/themes/. A theme Hugo cannot find shows up as a warning in the log and a blank page in the browser.

  • Restart and Persistence: restart: unless-stopped brings the container back after a reboot. Stop and start it with docker-compose stop hugo and docker-compose start hugo. Logs in the mounted volume survive both.

  • Production Considerations: drop --debug --verbose, build once with hugo build instead of running the dev server, mount only the directories the container needs, and set the base URL to the HTTPS address.

Caddy Reverse Proxy Setup

  • Reverse Proxy Configuration: Caddy takes requests for the domain and forwards them to the Hugo container on port 3000. The Caddyfile holds the routing, and Caddy gets the certificates from Let’s Encrypt without being asked.

  • Caddy Port Mapping: 0.0.0.0:8080:80 and 0.0.0.0:8443:443 bind all interfaces, so traffic from the internet reaches Caddy on host ports 8080 and 8443 and arrives at container ports 80 and 443. Nothing reaches Hugo except through Caddy.

  • Caddy Volume Mounts: ./Caddyfile:/etc/caddy/Caddyfile supplies the config. ./caddy_data:/data holds the certificates and Caddy’s state, which is the mount you least want to lose. ./caddy_config:/config keeps the autosaved config across restarts, and ./data/caddy/logs/:/data/logs/ puts access logs where you can read them.

caddy:
  image: caddy:latest
  container_name: caddy-reverse-proxy
  restart: unless-stopped
  ports:
    - "8080:80"
    - "8443:443"
  volumes:
    - ./Caddyfile:/etc/caddy/Caddyfile
    - ./caddy_data:/data
    - ./caddy_config:/config
    - ./data/caddy/logs/:/data/logs/
  environment:
    MEMOS_DOMAIN: ${MEMOS_DOMAIN}
  dns:
    - 8.8.8.8
    - 8.8.4.4
  • Caddyfile Example: point reverse_proxy at hugo:3000. Docker’s internal DNS resolves the service name, so no IP addresses appear anywhere.
example.com {
  reverse_proxy hugo:3000
}

www.example.com {
  reverse_proxy hugo:3000
}
  • SSL/TLS Handling: Caddy provisions and renews the certificates itself, and redirects HTTP to HTTPS out of the box.

  • Logging Access Requests: the access log in ./data/caddy/logs/ records every request, which is where you look first when a page 404s or a proxy returns 502.

  • Service Discovery: Caddy reaches Hugo by container name over the Docker network. Use hugo:3000 as the upstream. Inside a container, localhost is that container, so 127.0.0.1:3000 would point Caddy at itself.

Let’s Encrypt SSL/TLS Configuration

  • What is Let's Encrypt: a certificate authority that issues SSL/TLS certificates for free over an automated protocol, funded by Mozilla, the EFF, and others.

  • Automatic Certificate Provisioning: Caddy requests the certificate, answers the domain verification, installs the result, and stores it in the ./caddy_data:/data volume. You buy nothing and install nothing.

  • Caddy Caddyfile Configuration: name the domains in the Caddyfile. Caddy reads a bare hostname as a request for HTTPS and goes and gets the certificate.

example.com www.example.com {
  reverse_proxy hugo:3000
}

Caddy automatically:

  • Detects domains from Caddyfile
  • Contacts Let’s Encrypt ACME server
  • Verifies domain ownership via HTTP-01 challenge
  • Installs certificate
  • Configures HTTPS on ports 8080/8443

  • Certificate Renewal: certificates last 90 days, and Caddy renews at 30 days remaining. The renewal happens in the background with no restart and no downtime.

  • HTTPS Redirect: a visitor typing http://example.com lands on https://example.com. Prefix the hostname with http:// in the Caddyfile if you want to opt out.

  • Certificate Storage and Persistence: the ./caddy_data:/data volume holds them across restarts. Delete that volume and Caddy asks Let’s Encrypt for new certificates, which counts against your rate limit. Back it up.

  • Browser Trust and Security: every current browser trusts Let’s Encrypt, so visitors get the padlock and no warning interstitial.

  • Rate Limiting Considerations: Let’s Encrypt caps you at 50 certificates per domain per week. Test against their staging endpoint first, because a config you restart twenty times while debugging can burn through that.