AI-assisted spray wall route setting and climbing app.
- Docker & Docker Compose
- Node.js ^20.19.0 or ≥ 22.12.0 (for the web app)
- Go ≥ 1.22 (optional, for local dev outside Docker)
Prebuilt images are published to GHCR (ghcr.io/shelldandy/spraywall-{server,worker,web}), so all you need is a compose file:
mkdir spraywall && cd spraywall curl -LO https://raw.githubusercontent.com/shelldandy/spraywall/main/deploy/docker-compose.yml curl -L -o .env https://raw.githubusercontent.com/shelldandy/spraywall/main/deploy/.env.example # Set POSTGRES_PASSWORD, MINIO_ROOT_PASSWORD and JWT_SECRET (e.g. `openssl rand -hex 32` each) $EDITOR .env docker compose up -d
The app is served on port 80 (HTTP_PORT to change). Migrations run and the storage bucket is created automatically on start.
- Versions:
latesttracks the newest release,edgetracksmain; pin a release withSPRAYWALL_VERSION=1.2.3. - NVIDIA GPU: the default worker runs inference on CPU. Use the
-cudaworker tag (e.g.latest-cuda) and uncomment thedeployblock in the compose file. - Upgrading:
docker compose pull && docker compose up -d.
Spraywall can log users in through any OpenID Connect provider (Pocket-ID, Authelia, Authentik, Keycloak, ...). It is off unless OIDC_ISSUER_URL is set; the provider is configured via discovery (<issuer>/.well-known/openid-configuration). When enabled, the login page shows a "Sign in with OIDC_PROVIDER_NAME" button next to (or instead of) the password form.
| Variable | Default | Description |
|---|---|---|
OIDC_ISSUER_URL |
Issuer URL, e.g. https://id.example.com. Enables OIDC |
|
OIDC_CLIENT_ID |
Client ID (required when enabled) | |
OIDC_CLIENT_SECRET |
Client secret (required when enabled) | |
OIDC_REDIRECT_URL |
Callback URL, https://<your-host>/api/auth/oidc/callback (required when enabled) |
|
OIDC_PROVIDER_NAME |
SSO |
Label for the login button |
OIDC_SCOPES |
openid email profile groups |
Requested scopes |
OIDC_GROUPS_CLAIM |
groups |
Claim holding the user's group names |
OIDC_ADMIN_GROUP |
Members become instance admins; unconfirmed admins outside the group get setter rights, while confirmed admins removed from the group become climbers on their next SSO login. Other roles are preserved. If unset, roles are never touched | |
OIDC_ADMIN_MAX_AGE |
24h |
With OIDC_ADMIN_GROUP set, admin rights lapse this long after the user's last SSO login (sessions and password logins don't renew them), so removing someone from the group takes effect within this window. Signing in with SSO again restores them |
PASSWORD_LOGIN_ENABLED |
true |
false = SSO only (disables password login and registration). The server refuses to start unless OIDC is configured |
- In the Pocket-ID admin UI, create an OIDC client. Set the callback URL to
https://<your-host>/api/auth/oidc/callback. - Copy the client ID and secret into
.env:OIDC_ISSUER_URL=https://id.example.com OIDC_CLIENT_ID=<client id> OIDC_CLIENT_SECRET=<client secret> OIDC_REDIRECT_URL=https://<your-host>/api/auth/oidc/callback OIDC_PROVIDER_NAME=Pocket-ID
- To manage admins from Pocket-ID, create a group such as
spraywall-admins, add users to it and setOIDC_ADMIN_GROUP=spraywall-admins. Pocket-ID includes thegroupsclaim when thegroupsscope is requested (the default). - Optionally set
PASSWORD_LOGIN_ENABLED=falseto make SSO the only way in, thendocker compose up -d.
- Existing password accounts are linked automatically on first SSO login when the provider reports the email as verified. Because password sign-up never proves email ownership, linking this way removes the account's password, any previously linked identity, and all its sessions; the owner can set a new password from the Profile page. Accounts are only created or linked from verified emails (the provider must send
email_verified=truein the ID token or UserInfo; Pocket-ID does), and emails match case-insensitively. - Profile pictures are taken from the provider's
pictureclaim unless the user has uploaded their own. They are only downloaded from the provider's own host or from public addresses. - On the Profile page (Settings → Profile) users can change their display name and avatar, link or unlink the provider, and set or change a password. The last remaining login method cannot be removed.
Releases are cut by Release Please from Conventional Commits on main.
- Squash-merge PRs with a conventional title:
fix: …bumps the patch version,feat: …the minor,feat!: …a breaking change (minor while on 0.x). Other prefixes (chore:,docs:, …) don't trigger a release. - Release Please keeps a
chore(main): release X.Y.ZPR open with the version bump andCHANGELOG.md. - Merging that PR tags
vX.Y.Z, creates the GitHub release, and publishes images asX.Y.Z,X.Yandlatest(worker also with-cuda).
# Clone and configure git clone https://github.com/bowlinedandy/spraywall.git cd spraywall cp .env.example .env # Start backend services (Postgres, MinIO, Go server, Python worker) make dev # In another terminal — start the web app (proxies /api, /images, and /beta-media to :8080) make web
The app is a mobile-first PWA. Open it on your phone and use "Add to Home Screen" to install it. It caches the app shell, wall images and recently viewed data for offline browsing; changes need a connection.
marketing/ is a standalone, single-page HTML/CSS site. It uses local assets and system fonts, with no JavaScript, package installation, build step, or backend services.
Preview it from the repository root:
python3 -m http.server 4173 --directory marketing
# Open http://localhost:4173Validate the HTML structure, local links/assets, SVG, and Netlify configuration (Python 3.11+):
python3 scripts/check-marketing.py
Import this repository as a new Netlify project. The root netlify.toml sets:
| Setting | Value |
|---|---|
| Framework | None / Other |
| Base directory | Repository root (.) |
| Build command | Empty (no build) |
| Publish directory | marketing |
No environment variables, plugins, functions, or API proxies are needed. Only marketing/ is published; do not change the publish directory to the repository root. Netlify's Git integration can create Deploy Previews for pull requests. Check the page and its direct asset URLs in a preview before publishing to your production domain. If you use the Netlify CLI, netlify dev is configured to serve the static site.
This deploys the marketing site, not the Spraywall app. Host the app separately using the self-hosting instructions; its API, storage, worker, and PWA remain unchanged. The site links to those instructions rather than a hosted signup. If you later add canonical or Open Graph URL metadata, use the actual marketing domain, not a placeholder.
Climbers can add YouTube, Instagram, TikTok, and Immich video links to climbs. Immich links are resolved and streamed by the server, so Immich can remain accessible only on your LAN or tailnet. To enable Immich, set IMMICH_ALLOWED_HOSTS to a comma-separated list of exact hostnames (optionally including ports), without a scheme; for example photos.example.net,immich.tailnet:2283. The allow-list is required as an SSRF guard. Leave it empty to disable Immich links. YouTube, Instagram, and TikTok links work without this setting.
| Service | URL |
|---|---|
| Server | http://localhost:8080 |
| Postgres | localhost:5433 |
| MinIO | http://localhost:9000 |
| MinIO UI | http://localhost:9001 |
| Web app | http://localhost:5173 |
| Command | Description |
|---|---|
make dev |
Start all backend services (SAM enabled) |
make web |
Start the web app (Vite) dev server |
make migrate |
Run database migrations |
make sqlc |
Generate Go code from SQL queries |
make lint |
Run Go, TypeScript, and Python linters |
make test |
Run Go and Python tests |
make clean |
Stop services and remove volumes |
In production (make prod), nginx serves the built web app at / and proxies the API at /api/.
Note:
docker compose down -v(ormake clean) is required to re-runinfra/postgres/init.sql.
Hold detection uses YOLOv8 for bounding boxes. By default, Segment Anything Model 2.1 (SAM 2) refines each box into a polygon outline.
Worker Docker images (including the published ones) install SAM and download the checkpoint at build time. For local (non-Docker) runs and the make dev bind mount, download a SAM checkpoint into the worker models directory:
wget -P worker/models https://huggingface.co/facebook/sam2.1-hiera-small/resolve/main/sam2.1_hiera_small.pt
For local (non-Docker) runs, also install the optional sam extra (pip install '.[sam]'). To opt out, set SAM_ENABLED=false or build the image with INSTALL_SAM=false; detection then returns YOLO bounding boxes without polygon segmentation. If SAM is enabled but the package or checkpoint is missing, the worker logs a warning and falls back to boxes.
- SAM refines YOLOv8 bounding boxes into polygon masks
- OpenCV post-processing (erode/dilate/contour/simplify) cleans the masks
- Polygons are stored in the
holds.polygoncolumn and rendered as SVG polygons in the app - Holds without polygon data fall back to rectangle rendering
| Variable | Default | Description |
|---|---|---|
SAM_ENABLED |
true |
Enable SAM segmentation |
SAM_MODEL_CFG |
configs/sam2.1/sam2.1_hiera_s.yaml |
SAM 2 model configuration |
SAM_CHECKPOINT |
./models/sam2.1_hiera_small.pt |
Path to SAM 2 checkpoint file |
The worker polls for detection jobs and runs inference with one of two backends, chosen by INFERENCE_BACKEND:
local(default): runs YOLOv8 in the worker process (CUDA if available, otherwise CPU). SAM is enabled by default; setSAM_ENABLED=falseto skip it.modal: sends the image to a Modal serverless GPU (T4, per-second billing, scales down after 30 seconds idle). At the reviewed $0.59/hour rate, the idle tail is about $0.005 per isolated request; active compute is billed separately.
cd worker pip install modal modal token new modal run modal_app.py::download_weights # one-off: populate the models volume modal deploy modal_app.py modal run modal_app.py --image-path /path/to/wall.jpg # smoke test
After deploying a SAM model upgrade, re-run modal run modal_app.py::download_weights to populate the Modal volume with its checkpoint.
Then set these in .env and rebuild the worker:
| Variable | Default | Description |
|---|---|---|
INFERENCE_BACKEND |
local |
local or modal |
INSTALL_MODAL |
false |
Build arg: install the Modal client |
MODAL_TOKEN_ID |
Modal API token ID (from modal token new) |
|
MODAL_TOKEN_SECRET |
Modal API token secret |