Capture-the-flag platform for the Deakin University Cybersecurity Association (DUCA).
Production: https://ctf.duca.au
Individual play only. This platform is for solo participants — each person has their own account, solves, and leaderboard entry. Teams are not a feature of this project (no team registration, team scores, or team leaderboards).
Full guides live in docs/:
| Guide | Audience |
|---|---|
| docs/README.md | Documentation index |
| docs/admin.md | DUCA staff — running trimester CTFs, competitions, challenges, support |
| docs/users.md | Players — login, challenges, leaderboards, writeups |
| docs/developers.md | Developers — setup, deployment, architecture |
| docs/architechture.md | System design deep-dive |
- Passwordless email OTP authentication
- Competitions with scheduled challenges and countdown timers
- Multi-flag challenges with static scoring
- Live solves feed (SSE) and leaderboards
- Support chat with admin inbox
- Post-competition writeups (markdown or rich text)
- Admin panel for users, competitions, challenges, writeups, telemetry, and site pages
- All times displayed in AEST/AEDT (Australia/Sydney)
See architecture and the developer guide for system design.
| Layer | Technology |
|---|---|
| App | Next.js 15 (App Router, JavaScript) |
| Database | PostgreSQL 16 + Prisma 7 |
| Cache / pub-sub | Redis 7 |
| UI | Tailwind CSS + shadcn/ui (dark theme) |
| Production proxy | Caddy (external, via intranet_1 Docker network) |
Step-by-step detail: docs/developers.md. Organiser workflows: docs/admin.md.
- Node.js 20+
- Docker (for local PostgreSQL and Redis)
- SMTP credentials for sending login codes
git clone https://github.com/hirusha-adi/duca-ctf.git duca-ctf
cd duca-ctf
npm installsudo docker compose up -dThis starts:
| Service | Host port | Purpose |
|---|---|---|
duca-ctf-postgres |
5432 | PostgreSQL (duca_ctf database) |
duca-ctf-redis |
6379 | Rate limits + SSE pub/sub |
cp .env.example .env.localEdit .env.local. DATABASE_URL is read from prisma.config.mjs, not schema.prisma. Without REDIS_URL, the app falls back to in-memory stores (fine for single-process local dev).
npm run db:migrate
npm run db:seednpm run devOpen http://localhost:3000.
After a user registers and logs in:
npm run make-admin -- user@example.com| Script | Description |
|---|---|
npm run dev |
Next.js dev server |
npm run build |
Production build |
npm run start |
Start production build locally |
sudo docker compose up -d |
Start Postgres + Redis |
sudo docker compose down |
Stop Postgres + Redis |
npm run db:migrate |
Run Prisma migrations (dev) |
npm run db:migrate:deploy |
Apply migrations (production) |
npm run db:seed |
Seed default categories and site pages |
npm run db:purge-activity |
Delete activity logs older than 14 days |
npm run db:studio |
Open Prisma Studio |
npm run make-admin |
Promote a user to admin by email |
npm run db:backup |
Create a production DB backup (prod compose) |
npm run db:restore |
Restore DB from latest backup (prod compose) |
Production runs as four Docker containers behind Caddy. PostgreSQL and Redis communicate with the web app on a private bridge network (hirusha-duca-ctf-net). Only the web container also joins the external intranet_1 network so Caddy can reverse-proxy to it. All persistent data is stored in bind mounts under ./data/ and ./backups/.
┌─────────────┐
Internet ────────►│ Caddy │ (intranet_1)
└──────┬──────┘
│ :3000
┌──────▼──────────────────┐
│ hirusha-duca-ctf-web │
└──┬──────────────────┬───┘
hirusha-duca-ctf-net │
┌──────────┼──────────┬───────────────┘
│ │ │
┌───▼────┐ ┌───▼─────┐ ┌──▼─────┐
│postgres│ │ redis │ │ backup │ (daily cron)
└───┬────┘ └────┬────┘ └───┬────┘
│ │ │
./data/ ./data/ ./backups/
Before first deploy, create the data directories (git keeps the paths via .gitkeep files):
mkdir -p data/postgres/pgdata data/redis data/uploads backupsPostgreSQL requires an empty data directory on first start. The repo keeps data/postgres/.gitkeep for git, but the actual database files live in data/postgres/pgdata/ (which must stay empty until the container initializes it).
Create these dirs as your deploy user (no sudo). If you already ran sudo on data/, fix ownership before building:
sudo chown -R $USER:$USER data backups- Docker and Docker Compose v2 on the host
- External Docker network
intranet_1(shared with Caddy) - SMTP credentials
- DNS for
ctf.duca.au+ TLS handled by Caddy
Create the shared network once if it does not exist:
sudo docker network create intranet_1On the server, clone the repo and create .env from the production template:
cp .env.prod.example .envGenerate strong secrets with OpenSSL (run on the server, paste the output into .env):
# PostgreSQL password (32 hex chars)
openssl rand -hex 16
# Session secret — iron-session requires at least 32 characters (48 chars base64)
openssl rand -base64 36Copy the values into .env:
POSTGRES_PASSWORD=<output from first command>
SESSION_SECRET=<output from second command>And also fill all of the other values.
| Variable | Notes |
|---|---|
POSTGRES_PASSWORD |
Used by Postgres and the web app's DATABASE_URL |
SESSION_SECRET |
Signs/encrypts login cookies; must be at least 32 characters |
SMTP_* |
Your mail provider credentials (not generated) |
BUGSINK_DSN |
Optional. Bugsink error-tracking DSN (Sentry-compatible SDK). Set before docker compose build so browser errors are captured too. |
docker-compose.prod.yml sets DATABASE_URL, REDIS_URL, NODE_ENV, and UPLOAD_DIR automatically from POSTGRES_* values.
sudo docker compose -f docker-compose.prod.yml up -d --buildOn startup the web container runs prisma migrate deploy, seeds default categories and site pages (rules, terms, privacy), then starts Next.js.
sudo docker compose -f docker-compose.prod.yml exec hirusha-duca-ctf-web \
node scripts/make-admin.js user@example.comTo re-run the seed manually (idempotent):
sudo docker compose -f docker-compose.prod.yml exec hirusha-duca-ctf-web \
node prisma/seed.jsCaddy must be attached to intranet_1 so it can reach the web container by service name:
ctf.duca.au {
reverse_proxy hirusha-duca-ctf-web:3000 {
# Forward the visitor's public IP to the app for telemetry and solve logging
header_up X-Forwarded-For {remote_ip}
header_up X-Real-IP {remote_ip}
header_up X-Forwarded-Proto {scheme}
header_up Host {host}
}
}The app reads the visitor IP from proxy headers in this order:
X-Forwarded-For(first address in the list)X-Real-IPCF-Connecting-IP(if you terminate TLS at Cloudflare in front of Caddy)
Caddy is the only service that should reach hirusha-duca-ctf-web on intranet_1, so these headers are trusted. The web container is not published to the host, which prevents clients from bypassing Caddy and spoofing IPs.
Verify it works — after deploy, log in or submit a flag, then check Admin → Telemetry. The IP column should show your public address, not a Docker internal IP like 172.x.x.x.
# Quick header check from inside the intranet_1 network (optional)
sudo docker run --rm --network intranet_1 curlimages/curl:latest \
-sI -H "Host: ctf.duca.au" http://hirusha-duca-ctf-web:3000/ | grep -i forwardedIf IPs still show as 127.0.0.1:
- Confirm Caddy and
hirusha-duca-ctf-webare both onintranet_1 - Confirm the Caddyfile uses the container name
hirusha-duca-ctf-web:3000, notlocalhost:3000 - Reload Caddy after editing the Caddyfile:
sudo docker compose restart caddy
Example Caddy Docker Compose snippet
services:
caddy:
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
networks:
- intranet_1
networks:
intranet_1:
external: trueThe web container is not published to the host — Caddy is the only public entry point.
| Container | Network(s) | Notes |
|---|---|---|
hirusha-duca-ctf-web |
hirusha-duca-ctf-net, intranet_1 |
Next.js, port 3000 (expose only) |
hirusha-duca-ctf-postgres |
hirusha-duca-ctf-net |
No host port binding |
hirusha-duca-ctf-redis |
hirusha-duca-ctf-net |
No host port binding |
hirusha-duca-ctf-backup |
hirusha-duca-ctf-net |
Daily pg_dump at 03:00, keeps 3 rotating copies |
| Host path | Container path | Purpose |
|---|---|---|
./data/postgres/pgdata |
/var/lib/postgresql/data |
PostgreSQL data |
./data/redis |
/data |
Redis AOF persistence |
./data/uploads |
/app/data/uploads |
User/support/writeup uploads |
./backups |
/backups |
Gzip SQL dumps (duca_ctf_YYYYMMDD_HHMMSS.sql.gz) |
git pull
sudo docker compose -f docker-compose.prod.yml up -d --buildMigrations run automatically on container start.
View logs
sudo docker compose -f docker-compose.prod.yml logs -f hirusha-duca-ctf-webRestart a service
sudo docker compose -f docker-compose.prod.yml restart hirusha-duca-ctf-webShell into web container
sudo docker compose -f docker-compose.prod.yml exec hirusha-duca-ctf-web shhirusha-duca-ctf-postgres exits immediately
Check the logs:
sudo docker compose -f docker-compose.prod.yml logs hirusha-duca-ctf-postgresCommon causes:
| Log message | Fix |
|---|---|
directory exists but is not empty |
data/postgres/pgdata must be empty on first deploy. Remove stray files: rm -rf data/postgres/pgdata/* then mkdir -p data/postgres/pgdata and restart. |
Permission denied (postgres logs) |
Fix ownership: sudo chown -R 999:999 data/postgres/pgdata |
permission denied during docker compose up --build |
sudo on data/ left dirs root-owned. Fix: sudo chown -R $USER:$USER data backups then rebuild |
POSTGRES_PASSWORD is missing |
Create .env from .env.prod.example and set POSTGRES_PASSWORD |
After fixing, bring the stack back up:
sudo docker compose -f docker-compose.prod.yml up -dBackups are gzip-compressed SQL dumps written to ./backups/. The stack keeps only the 3 newest files — older backups are deleted automatically after every backup, including manual runs.
The hirusha-duca-ctf-backup container runs a cron job every day at 03:00 (container local time). It starts with the rest of the stack:
sudo docker compose -f docker-compose.prod.yml up -dView backup scheduler logs:
sudo docker compose -f docker-compose.prod.yml logs -f hirusha-duca-ctf-backupFrom the project root on the host:
npm run db:backup
# or
bash scripts/backup-db.shThis creates a new backups/duca_ctf_YYYYMMDD_HHMMSS.sql.gz and prunes older files down to 3.
List current backups:
ls -lh backups/Warning: restore replaces the current database contents.
Restore the latest backup (stops the web container during restore, then starts it again):
npm run db:restore
# or
bash scripts/restore-db.shRestore a specific backup:
bash scripts/restore-db.sh backups/duca_ctf_20250608_030001.sql.gzSkip the confirmation prompt:
bash scripts/restore-db.sh -yKeep the web container running (not recommended during restore):
bash scripts/restore-db.sh -y --no-stop-web backups/duca_ctf_20250608_030001.sql.gz| Variable | Default | Purpose |
|---|---|---|
BACKUP_KEEP_COUNT |
3 |
Number of rotating backups to retain |
POSTGRES_DB |
duca_ctf |
Database name used in dump filenames |
Set BACKUP_KEEP_COUNT in .env if you want a different retention count.
User activity (telemetry, per-user activity details, submission history) is kept on a rolling 14-day window. Records older than that are deleted automatically:
- On every web container start (production)
- Daily at 03:15 by the backup scheduler container
- On dev server startup (
src/instrumentation.js)
Override with ACTIVITY_LOG_RETENTION_DAYS in .env if needed.
The app uses the Sentry-compatible SDK (@sentry/nextjs) pointed at Bugsink. Set BUGSINK_DSN in .env.local (dev) or .env (production). When unset, error tracking is disabled.
Development
# .env.local
BUGSINK_DSN=https://<key>@bugsink.example.com/<project-id>Restart the dev server after adding the DSN. To verify, trigger a test error from application code (not the browser console):
throw new Error("Test error for Bugsink");Production
Add BUGSINK_DSN to .env before building the image — the DSN is embedded in the client bundle at build time:
sudo docker compose -f docker-compose.prod.yml up -d --buildServer-side errors also use the runtime BUGSINK_DSN from the container environment.
-
POSTGRES_PASSWORDandSESSION_SECRETset to strong random values - SMTP credentials verified (send a test login code)
-
intranet_1network exists and Caddy is attached - Caddy
reverse_proxypoints tohirusha-duca-ctf-web:3000 - Database seeded on first deploy
- At least one admin user promoted
-
data/andbackups/directories exist on the host -
hirusha-duca-ctf-backupcontainer is running - At least one manual backup verified (
npm run db:backup)
