Developer guide
The dev container is the supported way to develop the backend (gallery)
and web frontend (gallery-ui). It bundles the exact Go/Node/Yarn versions
CI uses, plus go vet, gofmt/goimports, golangci-lint, govulncheck,
nilaway, the OpenAPI codegen tools (redocly, swagger-typescript-api,
oapi-codegen), and the media-processing runtime deps (ffmpeg, mkvtoolnix,
yt-dlp) — so what passes locally is what passes in CI, and your host needs
only Docker, not a matching Go/Node toolchain. Your working copy is
bind-mounted in; nothing is baked into the image, so you edit with your
normal host tools and the container just runs it.
Native mobile/TV development (gallery-app, gallery-tv) can’t happen
inside the container — it needs Xcode and a simulator/device on your Mac —
so those still run on the host directly; see their sections below.
Running the backend/web frontend directly on your host without the container is possible but unsupported: see Local (Non-Container) Setup if you know what you’re doing and want to go that route anyway.
Table of Contents#
- Quick Start
- Configuration
- Day-to-Day Workflow
- OpenAPI Codegen
- Mobile App (gallery-app)
- TV App (gallery-tv)
- Backend Testing
- Linting
- Local (Non-Container) Setup
Quick Start#
Prerequisites: Docker Desktop (or another docker compose-compatible engine) and a HeroUI Pro CI/CD Token (not your Personal Token — see dashboard), needed to fetch @heroui-pro/react’s Pro components.
git clone <REPO_URL>
cd gallery
echo 'HEROUI_AUTH_TOKEN=your-token-here' >> .env # repo root, gitignored
./gallery.sh bootstrap # one-time: creates ./.dev-data/opt-gallery (gitignored) on the host
./gallery.sh dev-up # pulls (or builds) and starts the dev container
./gallery.sh rebuild # builds gallery-ui + the backend, (re)starts the server
http://localhost:8080 is now live, serving the real app against your
data. Keep editing — after any change, ./gallery.sh rebuild rebuilds and
hot-swaps in the new binary/frontend without recreating the container.
bootstrap copies your existing /opt/gallery (config, database, media
cache) into a gitignored ./.dev-data/opt-gallery inside the repo, since
Docker Desktop on Mac won’t bind-mount arbitrary host paths like
/opt/gallery directly. The container mounts that copy back to
/opt/gallery internally, so the dev config/DB paths (which are hardcoded
to /opt/gallery/..., see configuration.md)
work unmodified. If /opt/gallery doesn’t exist on your host yet,
bootstrap just creates an empty data dir and the app starts fresh.
dev-up tries to pull the published dev-latest image first and only
falls back to building locally if that fails (offline, or no dev image has
been published yet — see OpenAPI Codegen note on CI
below). Force a local build regardless with ./gallery.sh dev-build (e.g.
to pick up a Dockerfile.dev/dev/* change before it’s pushed by CI).
Other Commands#
./gallery.sh dev-status # shows supervisord's status for the server process
./gallery.sh dev-logs # tails the server logs
./gallery.sh dev-shell # drops into a shell in the running container - go vet, fmtcheck, utest, etc. all run here
./gallery.sh stop # stops the dev container
The server inside the container is kept alive by supervisord
(dev/supervisord.conf), which restarts it if it exits or is killed —
rebuild uses this (supervisorctl restart gallery) to hot-swap in a
freshly built binary.
Configuration#
Everything below is set via the .env file (gitignored) in the repo root —
docker compose loads it automatically, so echo 'KEY=value' >> .env is
enough for any of these. ./gallery.sh rebuild (and dev-up) recreate the
container if .env has changed since it was last created, so an updated
value (e.g. a new HEROUI_AUTH_TOKEN) is picked up automatically the next
time you run either.
| Variable | Default | What it does |
|---|---|---|
HEROUI_AUTH_TOKEN |
(empty) | HeroUI Pro CI/CD Token — see Quick Start. rebuild checks for this up front and fails fast with instructions if it’s missing. |
GALLERY_DEV_PORT |
8080 |
Host port published to the container’s (fixed, hardcoded-in-Go) internal port 8080. Override if something else on your machine already has 8080 bound. |
GALLERY_DEV_DATA_DIR |
./.dev-data/opt-gallery |
Host path bind-mounted to /opt/gallery inside the container — where the DB and config.toml persist. ./gallery.sh bootstrap creates this (and copies in an existing real /opt/gallery if one exists) before first use. Override to point at a real /opt/gallery on a Linux host, for example. |
GALLERY_DEV_MEDIA_DIR |
./.dev-data/media |
Host path bind-mounted to /config inside the container — where generated album/item files, thumbnails, and caches persist (the default Data.BasePath is /config/gallery/). Without it, that data is lost whenever the container is recreated. Docker creates the directory on first dev-up. |
DOCKERHUB_USERNAME |
mutablemango |
Combined with CONTAINER_NAME to form the local image tag (<user>/<name>:dev-latest). Only matters if you want the locally-built image tagged to match what CI would push. |
CONTAINER_NAME |
gallery |
See above. |
A few things that are configurable but only by editing files directly (no
.env plumbing for these yet):
- Toolchain versions —
Dockerfile.devpinsGO_VERSION(should trackgallery/go.mod),NODE_MAJOR, andYARN_VERSION(should trackgallery-ui’spackageManagerfield) as buildARGs with defaults at the top of the file. - Named volumes (
gallery-ui-node-modules,gallery-app-node-modules,go-mod-cache, etc. indocker-compose.dev.yml) — these shadownode_modules/build caches so a host-built (e.g. macOS)node_modulesnever leaks into the Linux container. Delete them withdocker volume rm(ordocker compose -f docker-compose.dev.yml down -v) to force a totally clean reinstall. - supervisord behavior (autorestart, log destination) —
dev/supervisord.conf.
The dev image itself is published by a separate CI pipeline
(build_dev_amd64/build_dev_arm64/create_dev_manifest in
.gitlab-ci.yml), triggered by pushing a dev-vX.Y.Z git tag — see the
comment block above those jobs for details.
Day-to-Day Workflow#
- Edit code on your host as normal (
gallery/,gallery-ui/, etc.) — the container only sees your checkout via the bind mount, so there’s nothing to sync. - Run
./gallery.sh rebuildto buildgallery-uiand the Go backend inside the container and hot-swap the running server. - Refresh
http://localhost:8080.
For quick backend-only iteration without rebuilding the frontend, use
./gallery.sh dev-shell to get a shell in the container, then run
go build -o gallery-dev-bin && supervisorctl restart gallery directly
from /workspace/gallery.
Everything gallery.sh offers for linting/testing is available from your
host — no need to dev-shell in first:
./gallery.sh vet # go vet
./gallery.sh fmtcheck # gofmt + goimports check
./gallery.sh lint # golangci-lint
./gallery.sh utest # unit tests
./gallery.sh itest # integration tests
vet, fmtcheck, lint, govulncheck, modcheck, utest, and itest
all transparently run inside the dev container (starting it if needed)
rather than on your host, so the only prerequisite on your machine is
Docker — not a matching Go toolchain, goimports, or golangci-lint.
codegen and nilaway always run in the container too, CI or not. (In CI,
vet/fmtcheck/lint/govulncheck/modcheck/utest/itest run
natively instead, since CI jobs already run in a plain Go image with no dev
container available — see gallery.sh’s $CI check.)
These are exactly what CI runs, so a clean run of all five is your best
signal a branch is ready to push. A change to gallery isn’t done until
all five pass — see Linting below for what golangci-lint
checks and how to fix or justify a finding.
OpenAPI Codegen#
The API contract lives in gallery/openapi/. Never edit bundled-openapi.yaml directly — it is generated.
After changing any file in gallery/openapi/, regenerate all clients and Go types:
./gallery.sh codegen
This always runs inside the dev container (starting it if needed) — no need to have redocly/swagger-typescript-api/oapi-codegen installed on your host.
This command:
- Bundles the split OpenAPI spec into
gallery/openapi/bundled-openapi.yamlviaredocly. - Generates the TypeScript API client for the web frontend (
gallery-ui/src/GalleryApiClient). - Generates the TypeScript API client for the mobile app (
gallery-app/GalleryApiClient). - Generates Go types into
gallery/types/api_types.gen.goviaoapi-codegen.
After regenerating, run ./gallery.sh rebuild to pick up the changes.
Mobile App (gallery-app)#
The mobile app uses Expo with React Native and targets iOS and Android. It builds and runs natively on your Mac — it does not run inside the dev container. All commands below are run from the gallery-app/ directory.
Prerequisites#
- Xcode (from the Mac App Store) with iOS simulators installed, Command Line Tools (
xcode-select --install) - CocoaPods:
sudo gem install cocoapods - Yarn/Node installed on your host (see Local (Non-Container) Setup)
cd gallery-app && yarn install
Run on iOS Simulator#
cd gallery-app
yarn ios
This runs expo prebuild (generates the native iOS project), installs CocoaPods dependencies, builds the app, and boots the default iOS simulator.
To choose a specific simulator, pass the --simulator flag:
yarn ios --simulator "iPhone 16 Pro"
List available simulators with:
xcrun simctl list devices available
Run on a Physical iOS Device#
Connect your iPhone via USB (or use wireless pairing from Xcode → Window → Devices and Simulators), then:
yarn ios --device
If you have multiple devices connected, Expo will prompt you to choose one. The device must be registered in your Apple Developer account and the bundle identifier (com.mutablemango.gallery) must be provisioned.
Starting the Metro Bundler Separately#
If you need to run the bundler without triggering a full native build:
yarn start
The app talks to the backend over the network, so point it at your dev container’s address (http://<your-lan-ip>:8080 from a physical device, or http://localhost:8080 from a simulator).
TV App (gallery-tv)#
The TV app targets Apple TV using react-native-tvos. It uses the same Expo workflow but the native project builds a tvOS target instead of iOS. Like gallery-app, it runs natively on your Mac, not in the dev container.
All commands below are run from the gallery-tv/ directory.
Prerequisites#
Same as the mobile app — Xcode with tvOS simulators installed, CocoaPods available.
cd gallery-tv && yarn install
Run on tvOS Simulator#
cd gallery-tv
yarn ios
This builds and launches the app in the default Apple TV simulator.
To target a specific simulator:
yarn ios --simulator "Apple TV 4K (3rd generation)"
List available tvOS simulators:
xcrun simctl list devices available | grep -i "apple tv"
Run on a Physical Apple TV#
Connect the Apple TV to the same network as your Mac and pair it via Xcode → Window → Devices and Simulators. Then:
yarn ios --device
As with the mobile app, the device must be registered in your Apple Developer account and the bundle identifier (com.mutablemango.gallerytv) provisioned.
Key Differences from the Mobile App#
| gallery-app | gallery-tv | |
|---|---|---|
| react-native package | react-native |
react-native-tvos (aliased) |
| Target OS | iOS / Android | tvOS |
| Orientation | Portrait | Landscape (forced) |
| Prebuild step | expo prebuild in yarn ios script |
handled automatically by yarn ios |
Backend Testing#
Always use gallery.sh instead of calling go test directly. The script cleans the cache, sets up the right working directories, and — unless $CI is set — runs the tests inside the dev container (starting it if needed), so your host needs only Docker, not a Go toolchain.
Unit Tests#
# Run all unit tests
./gallery.sh utest
# Run a single test by function name
./gallery.sh utest TestFunctionName
# Run all tests in a specific file (path relative to gallery/utest/)
./gallery.sh utest example_test.go
Integration Tests#
# Run all integration tests
./gallery.sh itest
# Run a single test by function name
./gallery.sh itest TestFunctionName
# Run all tests in a specific file (path relative to gallery/itest/)
./gallery.sh itest example_test.go
New tests go in gallery/utest/ or gallery/itest/ — not next to the implementation files. See CLAUDE.md for the full testing policy.
Linting#
./gallery.sh lint
Runs golangci-lint over gallery/, configured by gallery/.golangci.yml. This is separate from ./gallery.sh vet (plain go vet) and ./gallery.sh fmtcheck (gofmt/goimports) — all three are required, and CI runs all three (see lint/vet/fmtcheck jobs in .gitlab-ci.yml) alongside utest/itest on every branch and tag.
The config currently enables golangci-lint’s default linter set:
- errcheck — flags ignored error return values.
- govet — the same checks as
go vet, run through golangci-lint for consistency with the rest of the report. - ineffassign — flags assignments whose value is never read before being overwritten. This has repeatedly caught real bugs in this codebase, not just dead code — variable shadowing that silently swallowed an error (see
git logaroundmetadata/tmdb.goandmiddleware/permissions.gofor examples) shows up here first. Don’t dismiss an ineffassign finding as cosmetic without reading the surrounding control flow. - staticcheck — a broad static-analysis suite (bug detection, deprecated-API usage, and style/simplification suggestions).
- unused — flags dead functions, types, and variables. If a finding here is genuinely unused, delete it; don’t suppress it to preserve dead code (see CLAUDE.md).
This is a deliberately moderate starting set — see the comment at the top of gallery/.golangci.yml. Expect it to gain more linters (e.g. gosec, bodyclose) over time as the codebase is cleaned up for each one; don’t add a new linter to the enable list without first running it locally and either fixing or triaging what it finds, the same way this initial set was rolled out.
Test files are exempt from errcheck (gallery/.golangci.yml excludes _test.go, itest/, and utest/), since defer-cleanup calls like resp.Body.Close() or a test server’s Stop() are routinely ignored in test code industry-wide and checking every one is noise, not signal. Every other linter, including ineffassign and staticcheck, still runs on tests.
Fixing a finding:
- Most errcheck findings on cleanup calls (
Close,Remove,RemoveAll) should be made explicit rather than silently allowed to slide:defer func() { _ = f.Close() }(). If the call is on the write side of a file (you just wrote to it) and a logger is already in scope, log the close error instead of discarding it — a failed close on a write path can mean data never made it to disk. - Most staticcheck “quick fix” (
QF...) and style (S.../ST...) findings are safe, behavior-preserving rewrites — apply the suggested fix as-is. - If a finding is a real false positive or requires a design decision out of scope for the current change, suppress it narrowly with a
//nolint:<linter>comment on that line, with a short reason (see the ASD-STE100 comment style in CLAUDE.md) — never disable the linter globally to silence one finding.
golangci-lint results are cached; if a fix doesn’t seem to take effect, golangci-lint cache clean (installed as part of ./gallery.sh lint’s toolchain) rules out stale cache before you go looking for a bug in the config.
Local (Non-Container) Setup#
Unsupported, no guarantees — the dev container is what’s tested and what matches CI. Use this only if you specifically need to avoid Docker and you’re comfortable debugging toolchain/version mismatches yourself.
Note that ./gallery.sh vet/fmtcheck/lint/govulncheck/modcheck/
utest/itest always shell out to the dev container (unless $CI is
set), and codegen/nilaway always do regardless of $CI — none of them
work under this setup. Run go vet, gofmt/goimports, golangci-lint,
go test ./utest/..././itest/..., etc. directly instead.
Install via Homebrew:
brew install yarn node golang ffmpeg redocly-cli
Install global Node tools:
yarn global add swagger-typescript-api
yarn global add typescript
Install the Go OpenAPI code generator:
go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest
Add the Go bin directory to your shell’s PATH (add this to ~/.zshrc):
export PATH="$HOME/go/bin:$PATH"
Clone and install:
git clone <REPO_URL>
cd gallery
cd gallery-ui && yarn install && cd ..
cd gallery-app && yarn install && cd ..
cd gallery-tv && yarn install && cd ..
The backend reads its configuration from a TOML file. By convention the
development config lives at /opt/gallery/config/config.toml. Create the
directory and a minimal config if it does not already exist:
sudo mkdir -p /opt/gallery/config /opt/gallery/data
A minimal /opt/gallery/config/config.toml to get started:
[Server]
Port = 8080
SecureCookie = false
[Database]
Type = "SQLite"
[Database.SQLite]
Path = "/opt/gallery/config/gallery.db"
[Data]
BasePath = "/opt/gallery/"
[Auth]
UseSimple = true
UseSSO = false
[Auth.Simple]
JWTSecret = "change-me-in-production"
See configuration.md for the full reference of every available option.
Run the backend from the repository root:
cd gallery
go run main.go
The server accepts an optional -config / -c flag to point at a non-default config path:
go run main.go -c /path/to/config.toml
Run the web frontend:
cd gallery-ui
yarn dev
The Vite dev server starts on http://localhost:5173 by default and proxies API calls to the backend. To verify the production build:
yarn build
Always run yarn build after UI changes before considering work complete — it runs TypeScript compilation and catches type errors that the dev server may not surface.
Debugging with Delve#
go install github.com/go-delve/delve/cmd/dlv@latest
cd gallery
dlv debug
Set breakpoints inside dlv with:
break middleware/permissions.go:42