No description
  • Shell 69%
  • Smarty 20.5%
  • Dockerfile 10.5%
Find a file
claude 253fbb475b
All checks were successful
Build and publish Docker image / build (push) Successful in 1m43s
add browser caching headers to downstream nginx
Hugo hashes CSS/JS/font/image filenames so they can be cached for 1 year
with immutable. HTML pages get 5 minutes with must-revalidate since they
rebuild on every push. Upstream nginx passes these headers through unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:27:36 -04:00
.forgejo/workflows add CI workflow and harden deployment 2026-03-19 08:38:27 -04:00
.env.example fix .env.example to use ssh:// URL format 2026-03-19 09:12:07 -04:00
.gitignore initial commit: creating a temporal hugo setup with its own nginx for production use 2026-03-12 09:06:34 -04:00
docker-compose.yml rename compose service from 'site' to 'cairn' 2026-03-19 09:27:10 -04:00
Dockerfile install hugo and webhook via apk instead of manual downloads 2026-03-19 09:31:56 -04:00
entrypoint.sh expose webhook at /<hash> instead of /hooks/<hash> 2026-03-19 10:22:09 -04:00
hooks.json.tpl switch webhook signature header to X-Forgejo-Signature 2026-03-19 10:23:39 -04:00
nginx.conf.tpl add browser caching headers to downstream nginx 2026-03-19 11:27:36 -04:00
README.md add reverse proxy vhost examples to README; fix webhook URL 2026-03-19 10:52:17 -04:00
rebuild.sh recurse submodules on clone and pull 2026-03-19 09:41:52 -04:00
supervisord.conf add CI workflow and harden deployment 2026-03-19 08:38:27 -04:00

cairn

A single Docker image that serves a Hugo site via nginx, automatically rebuilding on Forgejo push via webhook, with ntfy notifications.

Stack

  • Hugo — static site builder
  • nginx — serves the built site on port 80; proxies webhook traffic internally
  • webhook — listens on port 9000, validates HMAC signatures, triggers rebuilds
  • supervisord — runs nginx and webhook together in a single container
  • ntfy — push notifications on build success/failure

Project Structure

cairn/
├── .env               # your config (never commit this)
├── .env.example       # template for .env
├── .forgejo/
│   └── workflows/
│       └── build.yml  # CI: builds and publishes Docker image to Forgejo registry
├── .gitignore
├── docker-compose.yml
├── Dockerfile
├── entrypoint.sh
├── rebuild.sh
├── hooks.json.tpl
├── nginx.conf.tpl
├── supervisord.conf
└── secrets/
    └── deploy_key     # SSH deploy key (never commit this)

Setup

1. Configure environment

cp .env.example .env

Edit .env with your values.

2. Generate a deploy key (SSH auth)

mkdir -p ./secrets
ssh-keygen -t ed25519 -C "hugo-deploy" -f ./secrets/deploy_key -N ""

Add the contents of ./secrets/deploy_key.pub to your Forgejo repo under: Repository → Settings → Deploy Keys (read-only access is sufficient)

3. Configure Forgejo webhook

In your Forgejo repo go to Settings → Webhooks → Add Webhook → Forgejo and set:

  • URL: http://your-server/<WEBHOOK_ENDPOINT>
  • Secret: the value of WEBHOOK_SECRET from your .env
  • Trigger: Push events

The full webhook URL is logged on container start. By default the endpoint is the SHA256 of "cairn". Set WEBHOOK_ENDPOINT_SEED in .env to use a different seed.

4. Run

Pull the pre-built image from the registry:

docker compose pull && docker compose up -d

Or build locally:

docker compose up --build

How it works

Forgejo push → nginx (:80) → webhook (:9000) → rebuild.sh → git pull + hugo build → nginx serves /output

On container start, the entrypoint will:

  1. Clone the repo if /site is empty
  2. Run an initial Hugo build
  3. Generate hooks.json and nginx.conf from templates
  4. Start nginx and webhook via supervisord

On every Forgejo push:

  1. nginx rate-limits (10 req/min) and proxies to webhook
  2. webhook verifies the HMAC-SHA256 signature
  3. rebuild.sh acquires a build lock, runs git pull then hugo --minify
  4. ntfy sends a success or failure notification (with full output on failure)

CI

Pushing to main or tagging v*.*.* triggers .forgejo/workflows/build.yml, which builds and publishes the Docker image to the Forgejo container registry.

Published tags:

  • sha-<short-sha> — every build
  • latest — on every push to main
  • <version> (e.g. 1.2.3) — on v*.*.* tags

Old sha-* versions are pruned from the registry after each build.

Authentication

Set AUTH_METHOD in .env to either:

  • ssh — uses the deploy key mounted at ./secrets/deploy_key (recommended)
  • https — uses GIT_USER and GIT_TOKEN from .env

Reverse proxy

If cairn sits behind an upstream nginx, configure a virtual host to proxy traffic through. The same vhost handles both the site and the webhook endpoint since they share port 80.

server {
    listen 80;
    server_name your-site.example.com;

    location / {
        proxy_pass http://127.0.0.1:1337;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

For HTTPS with Certbot:

server {
    listen 443 ssl;
    server_name your-site.example.com;

    ssl_certificate     /etc/letsencrypt/live/your-site.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/your-site.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:1337;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

server {
    listen 80;
    server_name your-site.example.com;
    return 301 https://$host$request_uri;
}

The webhook URL to configure in Forgejo would then be https://your-site.example.com/<WEBHOOK_ENDPOINT>.

Notes

  • SSH host key verification uses TOFU (trust on first use): on container start, host and port are parsed from GIT_REPO_URL and ssh-keyscan writes the key to known_hosts. Both ssh://git@host:2222/path and git@host:path URL formats are supported — port defaults to 22 for scp-style. The deploy key is copied from /run/secrets/deploy_key at startup so permissions can be set correctly. The risk of TOFU is acceptable for internal networks, further mitigated by restricting container egress via Docker networking.

  • The base image is alpine:latest. If you need fully reproducible builds, pin it to a specific version (e.g. alpine:3.21) in the Dockerfile.

  • Hugo is installed via apk and will be the standard (non-extended) build. The extended variant adds SCSS/Sass compilation and WebP encoding. If your site uses either of these features it will fail to build. If not, there is no practical difference.