After failing to adequately secure Photoprism as a public gallery, I gave up and caved in to using Hugo to generate a static site using hugo-theme-gallery.

To fit nicely into a Woodpecker Pipelines-based deployment, I made the potentially bad decision to stuff all the photos into a Git repo. Photos and index.md / _index.md are added as needed; on push, a Woodpecker pipeline builds and deploys the site to the final server in AWS EC2.

Hugo configuration

See also

[[Hugo actually getting started]] for initial setup

In hugo.toml:

baseURL = "https://gallery.example.com"
languageCode = "en-us"
title = "Photo Gallery"
 
disableKinds = ["taxonomy", "term"]
 
[module]
  [[module.imports]]
    path = "github.com/nicokaiser/hugo-theme-gallery/v4"

Ensure that there are no files in any of the following:

  • layouts/
  • static/
  • themes/ (this might get populated automatically by hugo’s build process)

Content goes into content/, starting with _index.md (note the leading underscore!) as the “homepage.” This top-level _index.md contains only:

---
title: "Photo Gallery"
---

No other content. The theme takes care of generating the page.

Album layout

Albums are generated directly from folders. content/ is the home page, and within that:

hugo.toml
content/
| _index.md        # home page
| album1/
| | index.md       # an album's "page"
| | photo1.jpg
| | photo2.jpg
| album2/
| | _index.md      # a high-level album's "page"
| | album3/
| | | index.md     # a nested album's "page"
| | | photo3.jpg
| | | photo4.jpg
| | album4/
| | | index.md
| | | photo5.jpg
| | | photo6.jpg

The actual “why” about index.md vs _index.md has to do with Hugo’s “page bundles” vs “leaf bundles.” TL;DR: use _index.md if that album will have sub-albums within it; otherwise, index.md.

In both cases (underscored and not-underscored) index files, the content is simply some YAML frontmatter declaring the title:

---
title: "Night Sky"
---

Automatic Deployment

Similarly to this notebook, the static site is first built on a server on my home network before upload to the EC2 host (which doesn’t have nearly enough compute power to do the build in any reasonable timeframe).

Woodpecker build process

Following the linked-above notebook logic, the gallery is built locally and then uploaded using a 2-job Woodpecker Pipelines:

# file: .woodpecker/10-hugo-build.yaml
 
labels:
  # do the CPU-intensive building on a more powerful server
  # on my home network
  hostname: microsrv
 
when:
  - event: push
    branch: master
  - event: manual
 
steps:
  # instead of a "don't post my full web URL" check, this is a
  # "don't post my location" guard.  Any GPS metadata is stripped
  # at build time; the files in the git repo *ARE* geotagged, but
  # it never makes it online.
  - name: wipe-location
    image: alpine
    commands:
      - apk update && apk add --no-cache exiftool
      - cd gallery/content
      - exiftool -gps*= $(find . -type f -name '*.JPG')
      - rm $(find . -type f -name '*.JPG_original')
    
  # do the actual build
  - name: build
    image: ghcr.io/gohugoio/hugo:latest
    commands:
      - cd gallery
      - hugo build
      - ls -lahR public
      
  # static site is built; deploy it to the EC2 server
  - name: upload-to-rain
    image: appleboy/drone-scp
    settings:
      host: 192.168.37.1
      port: 22
      username: misha
      target: /home/misha/landing/gallery/
      rm: true
      source: gallery/public/
      key:
        from_secret: rain_ssh_key
      passphrase: mmmmm_nope_not_putting_this_online_sorry

Once that runs, the deployment on the EC2 server runs:

# file: 20-deploy.yaml
labels:
  # run on the EC2 host to finalize deployment
  hostname: rain
 
when:
  - event: push
    branch: master
  - event: manual
 
depends_on:
  - 10-hugo-build
 
# no need to re-clone the repo, the prebuilt site has already been
# dropped off for us to continue from
skip_clone: true
 
steps:
  - name: deploy_to_site
    image: alpine
    volumes:
      - /opt/docker/gallery:/dest
      - /home/misha/landing/gallery/gallery/public:/src
    commands:
      - cp -r /src/* /dest/
      # this is a big chunk of drive space; don't keep duplicates around
      - rm -rf /src/*

Site hosting

I’m using Compose files on the EC2 server to host the static site:

services:
  gallery:
    container_name: gallery
    image: nginx
    restart: unless-stopped
    volumes:
      # this is where the 20-deploy.yaml Woodpecker job dropped
      # the built site
      - /opt/docker/gallery:/usr/share/nginx/html
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    mem_limit: 10m

The nginx.conf file:

events {
    worker_connections 1024;
}
http {
    sendfile on;

    server {
        listen 80;
        server_name localhost;
        root /usr/share/nginx/html;
        index index.html;
        error_page 404 /404.html;

        location / {
            include mime.types;
            try_files $uri $uri/ =404;
        }
    }
}

Reverse proxy

Last but not least, access to the container is routed through Caddy as a reverse proxy using the following config:

{
    admin off
    email misha@example.com
}

# Rate-limiting for public-facing hosts to prevent overloading
# the poor little 1GB-of-RAM EC2 so I can keep it in the (nearly)
# free tier
(public) {
    rate_limit {
        distributed
        zone everyone {
            key static
            events 100
            window 5s
        }
        zone individual {
            key {remote_host}
            events 30
            window 1s
        }
        log_key
    }
}

gallery.example.com {
    import public
    reverse_proxy http://gallery
}