Backend API for the Studánky (Springs) community platform — a hiking app for discovering and reporting the status of natural springs.
Built with Strapi v5 and TypeScript.
- Source-neutral canonical model — every data source is an adapter mapping into one model
- ČHMÚ sync — nightly cron + manual endpoint imports spring discharge data
- Denormalized map status — cached current status for a cheap map endpoint
- Public read API —
/springs/map(bbox) and/springs/:documentId/reports - Newsletter signup — isolated public write endpoint for collecting launch interest
- QR Codes — Auto-generated for each spring (encode
documentId) - Manager Access Control — admin users see only springs they manage
The app-facing MVP is read-only ČHMÚ data; community report submission (HMAC, geo-fence, rate limiting) is Phase 2. The only current public write is the isolated newsletter signup endpoint for the website; see API Security.
- Node.js 18–22
- npm 6+
cp .env.example .env
# Edit .env and set all required secretsnpm install
npm run devAdmin panel: http://localhost:1337/admin
npm test # run unit tests once (Vitest)
npm run test:watch # watch modeSee Testing for coverage and how to add tests.
npm run build
npm run startCustom backend logic is documented in docs/:
| Document | Description |
|---|---|
| API Reference | Mobile client contract — every endpoint the app consumes (map, detail, history, config) |
| ČHMÚ Sync | Source adapter, sync service, cron, manual endpoint |
| Public API | Custom endpoints: map (bbox) + report history |
| Denormalization | refreshLatest, cached status, flow scale |
| Database & Migrations | Indexes + why pairing is non-unique |
| Admin Filtering | Manager-based access control for Springs |
| Coolify Deployment | Coolify 4.1.2 native deployment: Dockerfile app + managed PostgreSQL |
| Lifecycle Hooks | QR code generation (denorm moved to a service) |
| API Security | MVP public surface + Phase 2 submit-security plan |
| Flutter Integration | Client submit contract (Phase 2, planned) |
| Roadmap | Next steps & Phase 2 / 3 plan |
| Testing | Running & writing the automated unit tests |
| Product spec · Backend design | Source-of-truth design docs |
| Variable | Description |
|---|---|
HOST |
Server host (default: 0.0.0.0) |
PORT |
Server port (default: 1337) |
APP_KEYS |
Application keys for session encryption |
API_TOKEN_SALT |
Salt for API token generation |
ADMIN_JWT_SECRET |
Secret for admin JWT tokens |
TRANSFER_TOKEN_SALT |
Salt for transfer tokens |
JWT_SECRET |
Secret for user JWT tokens |
ENCRYPTION_KEY |
Key for data encryption |
CRON_ENABLED |
Enable scheduled tasks incl. ČHMÚ sync (default true) |
HMAC_SECRET |
(Phase 2) shared secret for report-submit auth — unused in MVP |
IS_PROXIED |
Trust X-Forwarded-* behind Traefik (default true; set false for direct access) |
DOMAIN / PUBLIC_URL |
Public domain / explicit public URL for absolute links (QR, emails) |
CORS_ORIGINS |
Comma-separated allowed origins (default * — restrict in production) |
NEWSLETTER_SUBSCRIBE_MAX_BODY_BYTES |
Max newsletter subscribe payload size (default 8192) |
NEWSLETTER_EMAIL_RATE_LIMIT_* / NEWSLETTER_RATE_LIMIT_* |
In-memory secondary limiter by HMAC hash of normalized email |
DATABASE_* |
Postgres connection + pool settings (compose forces DATABASE_CLIENT=postgres) |
SMTP_* / DEFAULT_FROM_EMAIL |
Optional SMTP (nodemailer); auth attached only when enabled |
AWS_BUCKET + AWS_* / UPLOAD_CDN_* |
Optional S3/R2 upload offload (empty = local volume) |
COMPOSE_PROJECT_NAME |
Volume/container namespace per host (default studanky) |
ACME_EMAIL |
Email for Let's Encrypt registration |
BACKUP_* |
Backup cron schedules + retention (production only) |
Production runs as a single-node Docker Compose stack on an Ubuntu 24.04 server: Traefik (auto HTTPS via Let's Encrypt) → Strapi → PostgreSQL (WAL archiving) plus a backup container (pg_dump + pg_basebackup + WAL + uploads). Target: low-traffic, read-heavy public API + admin for spring owners.
⚠️ Do not scalestrapibeyond 1 replica. The ČHMÚ sync cron runs in-process; multiple replicas would fire it N× (duplicate reports, N× load on ČHMÚ). Scale vertically instead.Deploying with Coolify 4.1.2 instead? Use the Coolify-native guide in
docs/coolify-deploy.md. It keeps this VM Compose deployment intact and uses the sameDockerfilefor the Coolify application.
Internet :80/:443
│
┌────▼─────┐ traefik — TLS termination + HTTP→HTTPS redirect
└────┬─────┘
│ (docker network: studanky-network)
┌────▼─────┐ studanky-app — Strapi :1337 (non-root, healthcheck)
└────┬─────┘
┌────▼─────┐ ┌──────────────────┐
│ postgres │◄─────│ studanky-backup │ cron: dump+base+WAL+uploads
│ + WAL │ └──────────────────┘
└──────────┘
# Docker Engine + Compose v2 plugin
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
docker compose version # verify the v2 pluginPoint your DNS A record for DOMAIN at the server's public IP, and open
ports 80 and 443 in the firewall (sudo ufw allow 80,443/tcp).
# On the server, in the project root:
cp .env.example .env
# Generate each secret (run 6× for APP_KEYS×2 + the others):
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# Edit .env — required: DOMAIN, ACME_EMAIL, DATABASE_PASSWORD, all secrets,
# HMAC_SECRET; set CORS_ORIGINS to the app/admin domains; COMPOSE_PROJECT_NAME.
# Build and start the full production stack:
docker compose -f docker-compose.yml up -d --build
# Watch startup (first run also provisions the TLS certificate):
docker compose -f docker-compose.yml logs -f strapi traefikCreate the first admin at https://<DOMAIN>/admin.
If the server already runs a reverse proxy on :80/:443 (e.g. a host-level
nginx shared with another site), do not start the bundled Traefik — two
proxies cannot bind the same ports. Use the
docker-compose.host-nginx.yml override: it
disables Traefik and publishes Strapi on 127.0.0.1:1337 only, so the host
proxy reverse-proxies to it and terminates TLS (e.g. certbot).
docker compose -f docker-compose.yml -f docker-compose.host-nginx.yml up -d --buildThen add a vhost on the host nginx (/etc/nginx/sites-available/studanky):
server {
server_name studanky.example.com;
client_max_body_size 50M; # media uploads via admin
location / {
proxy_pass http://127.0.0.1:1337;
proxy_http_version 1.1;
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; # IS_PROXIED=true trusts this
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 120s;
}
listen 80;
}sudo ln -s /etc/nginx/sites-available/studanky /etc/nginx/sites-enabled/studanky
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d studanky.example.com # adds listen 443 + HTTP→HTTPS redirectKeep IS_PROXIED=true (Strapi trusts X-Forwarded-Proto for absolute URLs) and
set CORS_ORIGINS to the real domain (never *). Every redeploy must pass both
-f flags, otherwise the base file would start Traefik.
git pull
docker compose -f docker-compose.yml up -d --build
docker image prune -f # optional: reclaim old layersdocker compose up (without -f) merges docker-compose.override.yml, which
drops Traefik + backup and exposes Strapi directly on :1337. For plain
non-Docker dev use npm run dev (SQLite).
By default uploads live in the *_uploads volume and are archived by the backup
container. To offload to S3 / Cloudflare R2 (recommended once owners upload
photos), set AWS_BUCKET + credentials in .env (for R2 also AWS_ENDPOINT +
AWS_FORCE_PATH_STYLE=true) and set UPLOAD_CDN_HOST so the admin can preview
media (CSP).
Schedules (defaults): logical dump 02:30, base + uploads + cleanup 03:00, retention 7 days — all before the 03:30 ČHMÚ sync.
# Run a full backup now / list backups
docker exec studanky-backup /scripts/pg-backup.sh all
docker exec studanky-backup /scripts/pg-backup.sh list
# Restore the latest logical dump (destructive — asks for confirmation)
docker exec -it studanky-backup /scripts/pg-restore.sh dump latest
docker restart studanky-app # reconnect Strapi after a DB restore
# Restore uploads / Point-in-Time Recovery guide
docker exec -it studanky-backup /scripts/pg-restore.sh uploads latest
docker exec studanky-backup /scripts/pg-restore.sh pitr-info
⚠️ Backups are on the same host as the DB. Add an off-site copy (e.g.restic/rcloneof the*_backupsvolume to S3/R2) — the report history is the project's core asset.
For deeper background see the Strapi deployment docs.