No description
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-29 21:59:03 -05:00
docs/superpowers Record the UGOS parser hazard and mark the plan as executed 2026-09-07 20:18:33 -05:00
scripts Fix silent secret corruption from $ in vault values (C1) 2026-09-07 11:41:26 -05:00
tests Fix silent secret corruption from $ in vault values (C1) 2026-09-07 11:41:26 -05:00
.env.example Add AdventureLog compose template and its inputs 2026-09-07 11:13:22 -05:00
.gitignore Add AdventureLog deployment design 2026-09-07 10:36:10 -05:00
deploy.env Send invite mail from adventure@bobparsons.dev 2026-09-07 18:01:24 -05:00
docker-compose.yml Drop the config file mode so UGOS accepts the compose file 2026-09-07 20:12:52 -05:00
README.md Record the UGOS parser hazard and mark the plan as executed 2026-09-07 20:18:33 -05:00
renovate.json chore(renovate): DB image majors need explicit approval 2026-09-29 21:51:55 -05:00

AdventureLog

AdventureLog at https://adventure.waugze.com — private, invite-only trip and activity planning.

This service runs on the NAS (192.168.86.201), not on the server. AdventureLog is GeoDjango and needs PostGIS, which the shared postgres18 instance does not have. Rather than maintain a custom shared image and restart the database Zitadel and Umami depend on, the stack keeps its own PostGIS container beside the app. Design rationale: docs/superpowers/specs/2026-09-07-adventurelog-deployment-design.md.

Shape

Cloudflare (proxied CNAME → tunnel)
  → cloudflared → traefik:443              [server 192.168.86.203]
  → file-provider router `adventurelog`    [traefik repo, traefik.yml]
  → http://192.168.86.201:8015             [LAN, plaintext]
  → adventurelog + adventurelog-db         [NAS 192.168.86.201]

There is no Forgejo workflow here. UGOS provides no secret store for Docker projects, and opening SSH to a CI runner is a larger attack surface than this service warrants, so deploys are a deliberate manual step.

Deploy

INFISICAL_PROJECT_ID=<project-id> ./scripts/render.sh

Pulls secrets from the prod Infisical environment by default; set INFISICAL_ENV to render against a different one.

That pulls the secrets, merges the non-secret deploy.env, and writes build/docker-compose.yml with every value inlined. build/ is gitignored and holds live secrets in plaintext. Do not commit it.

Before the first deploy, create the two bind-mount directories on the NAS array (/volume1/docker/adventurelog/media and /volume1/docker/adventurelog/db) — if they don't exist yet, Docker creates them itself, possibly at a path UGOS resolves differently, which may not even be on the array.

Check the artifact carries no placeholder before you ship it — render.sh rejects a missing or empty secret, but a secret whose value is a stand-in renders as happily as a real one:

grep -E 'replace|changeme|dummy' build/docker-compose.yml \
  && echo "PLACEHOLDER PRESENT — fix the secret in Infisical before deploying" \
  || echo "no placeholders"

Copy that file into the UGOS Docker Project for adventurelog and apply it.

To change routing rather than the app, edit traefik.yml in the traefik repo — that one still deploys through Forgejo on push to main.

Secrets

Infisical, dedicated project, prod environment, path /:

Key
POSTGRES_PASSWORD shared by the app and db services
SECRET_KEY Django signing key; openssl rand -hex 32
DJANGO_ADMIN_USERNAME first-boot superuser; inert on later boots
DJANGO_ADMIN_PASSWORD
DJANGO_ADMIN_EMAIL
EMAIL_HOST_PASSWORD purelymail password for EMAIL_HOST_USER

Non-secret host settings are committed in deploy.env: MEDIA_PATH, DB_PATH, EMAIL_HOST_USER, DEFAULT_FROM_EMAIL.

Hazards

  • docker compose config rewrites relative bind paths against the machine doing the rendering. MEDIA_PATH and DB_PATH must stay absolute NAS paths or the stack silently points at your workstation. scripts/render.sh enforces this — it refuses to render if either one doesn't start with /, naming the offending key.

  • Never add an env_file: directive to docker-compose.yml. config inlines environment: but leaves env_file as a path reference, and the NAS has no .env beside the compose file. Upstream's compose file uses one; ours must not.

  • The tunnel entry needs No TLS Verify. Cloudflare's public-hostname entry for adventure.waugze.com must have it enabled, or cloudflared validates the origin certificate against the literal name traefik, Traefik serves its self-signed default, and the hostname 502s while everything else looks fine. The setting is per-entry and is not inherited.

  • Cloudflare's free plan caps request bodies at 100 MB, which bounds photo uploads. BODY_SIZE_LIMIT is set to match so the app's own limit is not the confusing one.

  • The image's nginx config is overridden, and that override must be re-checked on every upgrade. Django routes /invitations/ but the AIO image's nginx proxies only /(media|admin|accounts) to it, so every emailed account-invite link 404s on the SvelteKit frontend. Upstream bug, present in v0.13.0 and on main. docker-compose.yml carries a corrected copy inline as a Compose config (needs Compose >= 2.23; the NAS has 2.26.1), which keeps the rendered file the single artifact to copy over.

    On an image upgrade, diff the inline copy against docker/shared/nginx-aio.conf at the new tag and re-apply the one-word change — a stale copy silently reverts upstream's nginx fixes. If upstream fixes the routing, drop the override entirely.

    Inside that block every $ is written $$. Compose interpolates this file, so a bare $host would be eaten as an empty variable. The rendered artifact keeps the $$, and Compose resolves it to a literal $ when the stack is applied on the NAS — verified against a real container, not assumed.

  • UGOS's compose parser is stricter than Docker's. It rejected the rendered file with Expected Number because docker compose config emits mode: "0444" — a quoted string where the schema wants a number. Compose normalises and quotes numeric fields on output (memory limits become memory: "3221225472", ports published: "8015"), so a field Docker accepts can still fail in the UGOS UI. If a future compose change is rejected there, look for a quoted number before suspecting anything else, and prefer omitting a field whose default is already what you want.

  • First boot is slow. Migrations plus the world/country data import take several minutes; the healthcheck allows a 300s start period. A container that looks unhealthy for the first few minutes is normal.

Invites and collaboration

Two different mechanisms, and the difference matters:

  • Collection invites are the collaboration feature and need no privileges. Any user shares a Collection with any other existing user; the recipient accepts at /invites. Both then edit that trip's locations, transportation, lodging, notes and checklists.
  • Account invites are how a new person gets onto the instance at all — django-invitations, sent from /admin/invitations/invitation/add/. They work even though DISABLE_REGISTRATION=True, which is exactly the intended posture. They require is_staff and working SMTP.

Granting invite rights without opening the admin

Django's admin index renders only the models a user holds permissions on, so a narrowly-scoped group turns is_staff into invite-only access:

  1. /admin/auth/group/add/ → name Inviters → add exactly invitations | invitation | Can add invitation and invitations | invitation | Can view invitation. Nothing else.
  2. /admin/auth/user/ → the user → tick is_staff (not is_superuser) → add to Inviters → save.

Their /admin then contains the invite form and nothing else — no other models, no delete actions. The operator's superuser stays the break-glass account.

Backups

This stack is not covered by the postgres repo's pre-deploy pg_dumpall. Both bind mounts live on the NAS array and inherit its snapshot schedule. For a logical dump alongside that:

mkdir -p /volume1/docker/adventurelog/backups
docker exec adventurelog-db pg_dump -U adventure database \
  | gzip > /volume1/docker/adventurelog/backups/adventurelog-$(date +%F).sql.gz

Media is plain files under MEDIA_PATH; it needs no dump, only the snapshot.

Tests

./tests/test-render.sh

Running the tests deletes build/docker-compose.yml. They render with dummy secrets and clean up after themselves, which is right — leaving a dummy-valued artifact where the real one belongs is how you deploy a broken stack. So render after testing, never before, or you will cat a file that is no longer there.

Exercises scripts/render.sh against a stubbed Infisical CLI: a complete export renders, inlines secrets, and is written at mode 600; a missing secret fails and names itself; a secret present but set to an empty string also fails and names itself; an empty export fails.