LDS Stake Portal

Docker Deployment

Complete guide to deploying the LDS Stake Portal in production using pre-built container images from GHCR.

This guide walks through a complete production deployment using pre-built Docker images published to GitHub Container Registry (GHCR). You do not need to clone the source repository. All application code runs inside the containers. The host only needs Docker and a handful of configuration files.

This is the production path. For local development or contributing to the project, see Manual Deployment instead.


Before You Start: DNS First

Configure DNS before running docker compose up. Caddy provisions Let's Encrypt TLS automatically via the HTTP-01 ACME challenge, which requires your hostnames to resolve to your server.

Without Cloudflare proxy (direct DNS)

Create three A records pointing to your server's public IP:

RecordTypeValue
yourstake.orgA<your-server-IP>
api.yourstake.orgA<your-server-IP>
docs.yourstake.orgA<your-server-IP>

One A record plus a wildcard CNAME covers all subdomains — simpler than three separate A records:

RecordTypeValueProxy
yourstake.orgA<your-server-IP>Proxied (orange)
*.yourstake.orgCNAMEyourstake.orgProxied (orange)
www.yourstake.orgCNAMEyourstake.orgProxied (orange)

The wildcard CNAME covers api.yourstake.org, docs.yourstake.org, and www.yourstake.org automatically.

Caddy's HTTP-01 ACME challenge works through Cloudflare proxy. Cloudflare exempts /.well-known/acme-challenge/ paths from its "Always Use HTTPS" redirect, so the challenge reaches Caddy on port 80. Caddy provisions valid Let's Encrypt certificates for each hostname and serves them on port 443; Cloudflare Full (Strict) validates these against the origin.

Required Cloudflare SSL/TLS setting: Go to Cloudflare dashboard → your domain → SSL/TLS → Overview and select Full (strict). This ensures the Cloudflare → origin connection uses your valid Let's Encrypt cert. Do not use Flexible mode (HTTP to origin, insecure) or Full without Strict (accepts any cert including self-signed).

DNS must propagate and port 80 must be publicly reachable before you start the stack. Caddy provisions certificates on first startup — if any hostname is unreachable, Caddy logs an error and retries on a backoff schedule. Check with docker compose logs caddy if certificate provisioning is slow.


Step 1: Prepare the Server

Install Docker on a fresh Linux server (Ubuntu 22.04 LTS or later recommended).

# Update the system
sudo apt update && sudo apt upgrade -y

# Install Docker (official script)
curl -fsSL https://get.docker.com | sudo sh

# Add your user to the docker group (log out and back in after this)
sudo usermod -aG docker $USER

# Open required ports
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

Verify Docker is running:

docker --version
docker compose version

You do not need Node.js, Python, or uv on the host. All application runtimes are inside the containers.


Step 2: Create the Deployment Directory

Create a dedicated directory for all configuration files. Only configuration lives here — no source code.

sudo mkdir -p /opt/lds-stake-portal/backend
sudo mkdir -p /opt/lds-stake-portal/frontend
sudo chown -R $USER:$USER /opt/lds-stake-portal
cd /opt/lds-stake-portal

Your final directory structure will look like this:

/opt/lds-stake-portal/
├── docker-compose.yml
├── Caddyfile
├── .env                    # DOMAIN (and optionally GHCR_REPO)
├── backend/
│   ├── .env
│   └── wards.csv
├── frontend/
│   └── .env
└── discordbot/             # optional, only if using the Discord bot profile
    └── .env

Step 3: Download the Compose and Caddy Files

Copy docker-compose.yml and Caddyfile from the repository to your deployment directory. You can download them directly from GitHub without cloning:

cd /opt/lds-stake-portal

# Replace rc27122/LDS_Stake_Website-Template-2 with your fork if applicable
REPO="rc27122/LDS_Stake_Website-Template-2"

curl -fsSL "https://raw.githubusercontent.com/${REPO}/main/docker-compose.yml" -o docker-compose.yml
curl -fsSL "https://raw.githubusercontent.com/${REPO}/main/Caddyfile" -o Caddyfile

Alternatively, copy them from a local checkout if you have one.


Step 4: Create Environment Files

Root .env (Caddy domain + optional GHCR override)

# /opt/lds-stake-portal/.env

# Your stake's domain. Caddy provisions TLS for DOMAIN, api.DOMAIN, and docs.DOMAIN.
DOMAIN=yourstake.org

# Only needed if you have forked the repository and pushed images to your own GHCR namespace.
# If using the upstream images, leave this commented out.
# GHCR_REPO=your-github-org/your-fork-repo-name

backend/.env

# /opt/lds-stake-portal/backend/.env

DEV=false
PORT=8000

# Database
DB_ENGINE=sqlite
SQLALCHEMY_ECHO=false
# Must use this path — it maps to the backend-data named volume inside the container.
DATABASE_PATH=/app/data/database.db

# Required on FIRST BOOT ONLY when the database is empty.
# The backend creates [email protected] with this password.
# After first login and password change, this variable is no longer read.
INITIAL_ADMIN_PASSWORD=choose-a-strong-temporary-password

# Required — must be at least 64 characters.
# Generate with: openssl rand -hex 32
JWT_SECRET_KEY=

# Required in production — marks the refresh_token cookie as Secure.
# Even though Caddy handles TLS termination externally, the backend must still
# set the Secure flag on session cookies to prevent interception. Always true in production.
SSL_ENABLED=true

# Calling proposal approval thresholds
SP_APPROVAL_THRESHOLD=2
HC_APPROVAL_THRESHOLD=3

# CORS — set to your public frontend URL
ALLOWED_ORIGINS=https://yourstake.org

# Base URLs used in email links. Both default to localhost when unset,
# which makes all emailed links non-functional in production.
FRONTEND_BASE_URL=https://yourstake.org
BACKEND_BASE_URL=https://api.yourstake.org

# Email — Brevo (primary, 300 emails/day on the free tier)
BREVO_API_KEY=
BREVO_FROM_EMAIL=[email protected]

# Email — Mailjet (fallback when Brevo fails, 200 emails/day on the free tier)
MAILJET_API_KEY=
MAILJET_SECRET_KEY=
MAILJET_FROM_EMAIL=[email protected]

# Reply-to address shown to email recipients — use a monitored clerk inbox
REPLY_TO_EMAIL=[email protected]

# Google Calendar integration (optional — omit to disable silently)
# Path inside the container where the service account JSON is mounted.
GOOGLE_SERVICE_ACCOUNT_FILE=/run/secrets/google-sa.json
GOOGLE_CALENDAR_ID=[email protected]

# Discord bot integration (optional — omit both to disable silently)
# Must match BACKEND_TOKEN in discordbot/.env exactly.
DISCORD_BOT_TOKEN=
DISCORD_BOT_URL=http://discordbot:8001

JWT_SECRET_KEY must be at least 64 characters. The backend refuses to start if it is shorter or matches a known weak placeholder such as secret, changeme, or supersecret. Generate a valid key with openssl rand -hex 32 (produces exactly 64 hex characters). Do not use openssl rand -base64 32 — that produces only 44 characters and will fail the length check.

SSL_ENABLED=true is required in all production deployments. Caddy handles TLS externally, but the backend uses this flag to mark the refresh_token cookie as Secure. A cookie without the Secure flag can be transmitted over non-HTTPS connections and intercepted. The container's default is false — always override it in backend/.env.

frontend/.env

# /opt/lds-stake-portal/frontend/.env

# Internal Docker Compose service name — do not use localhost.
BACKEND_URL=http://backend:8000
PORT=3100
NODE_ENV=production

BACKEND_URL is read once when the Express server starts. Changing this value requires a full container restart — reloading the process inside the container has no effect.


Step 5: Create backend/wards.csv

The backend bind-mounts ./backend/wards.csv into the container at /app/wards.csv. This file must exist before you run docker compose up, or the backend will start with no wards and /api/wards will return empty.

Create the file with your stake's wards. The format is name,start_hour where start_hour is a decimal representing the meeting start time (9.0 = 9:00 AM, 13.5 = 1:30 PM):

name,start_hour
Riverside Ward,9.0
Eastside Ward,11.0
Westview Ward,13.5
Singles Ward,9.0

Add a row for every ward in your stake. Wards are inserted on first boot and on every subsequent restart for any rows not yet in the database. Existing rows are never overwritten, so it is safe to add new wards to this file without affecting existing records.


Step 6: Discord Bot (Optional)

The Discord bot runs under the discord Compose profile and does not start by default. To enable it, create discordbot/.env:

sudo mkdir -p /opt/lds-stake-portal/discordbot
# /opt/lds-stake-portal/discordbot/.env

DISCORD_TOKEN=your-discord-bot-token
BACKEND_URL=http://backend:8000
# Must match DISCORD_BOT_TOKEN in backend/.env exactly.
BACKEND_TOKEN=your-shared-secret

BACKEND_TOKEN in discordbot/.env and DISCORD_BOT_TOKEN in backend/.env must be set to the same value. The backend uses DISCORD_BOT_TOKEN for three purposes: (1) it creates a discord-bot@localhost system user with this value as the password, (2) it sends Authorization: Bearer {DISCORD_BOT_TOKEN} on all outbound HTTP calls to the Discord bot, and (3) the Discord bot verifies incoming requests by checking the Bearer token against BACKEND_TOKEN. A mismatch between the two env files causes all backend-to-bot calls to fail with 401 errors.

Discord bot data is not persisted. The docker-compose.yml defines no named volume for the Discord bot service. Its SQLite database — which stores Discord user-to-portal account mappings — lives in the container's writable layer and is destroyed every time the container is recreated. After any docker compose down followed by docker compose up --profile discord, all users must re-link their Discord accounts. As a workaround, add a discordbot-data volume entry in docker-compose.yml:

discordbot:
  volumes:
    - discordbot-data:/app/data

volumes:
  discordbot-data:

Step 7: Authenticate to GHCR (If Packages Are Private)

GHCR package visibility defaults to match the repository visibility. If the repository or its packages are private, docker compose pull fails with an authentication error.

Check visibility at https://github.com/rc27122/LDS_Stake_Website-Template-2/packages. If the packages are marked Private:

  1. Create a GitHub personal access token with the read:packages scope at https://github.com/settings/tokens.
  2. Authenticate Docker:
export CR_PAT=your-github-pat
echo "$CR_PAT" | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdin

You only need to do this once per server. The credentials are cached in ~/.docker/config.json.


Step 8: Pull Images and Start the Stack

cd /opt/lds-stake-portal

# Pull all images from GHCR
docker compose pull

# Start all services in the background
docker compose up -d

# To also start the Discord bot:
# docker compose --profile discord up -d

Always run docker compose pull before docker compose up -d. The docker-compose.yml contains both build: and image: directives (so the same file works for development builds too). If you skip the pull step and no local image is cached, Compose falls back to building from source — which requires the full repository checkout and all build dependencies on the host.

Check that all containers came up:

docker compose ps

Expected output — all services should show running or healthy:

NAME          IMAGE                                           STATUS
caddy         caddy:2-alpine                                  running
backend       ghcr.io/.../backend:latest                      healthy
frontend      ghcr.io/.../frontend:latest                     healthy
docs-site     ghcr.io/.../docs-site:latest                    running

Step 9: Verify the Deployment

View backend startup logs (look for "Application startup complete"):

docker compose logs -f backend

Check the backend health endpoint directly:

curl http://localhost:8000/health

Expected response:

{"status": "ok"}

The health endpoint is /health, not /api/health. The /api/health path does not exist. Use http://localhost:8000/health for a direct check (bypasses Caddy) or https://api.yourstake.org/health once DNS and TLS are provisioned.

Once Caddy has provisioned TLS certificates (watch docker compose logs caddy for "certificate obtained"), verify end-to-end:

# Frontend via Caddy
curl -I https://yourstake.org

# Backend via Caddy
curl https://api.yourstake.org/health

# Docs site via Caddy
curl -I https://docs.yourstake.org

Then open https://yourstake.org/login in a browser and log in with [email protected] and your INITIAL_ADMIN_PASSWORD. See First Run and Bootstrap for what to do after first login.


Container Images

Images are published to GHCR and are lowercase-normalised:

ServiceImage
backendghcr.io/rc27122/lds_stake_website-template-2/backend:latest
frontendghcr.io/rc27122/lds_stake_website-template-2/frontend:latest
docs-siteghcr.io/rc27122/lds_stake_website-template-2/docs-site:latest
discordbotghcr.io/rc27122/lds_stake_website-template-2/discordbot:latest

Each build also produces an immutable SHA tag in the format sha-<7-char-commit-sha> (for example, sha-f371827). The SHA tag lets you pin to a specific build for rollback. See Upgrading for how to use SHA tags.

CI builds are manual. The GitHub Actions workflow (build-push.yml) uses workflow_dispatch and is triggered manually — not automatically on every commit or push. After merging changes, go to the Actions tab in GitHub, select the "Build and Push Docker Images" workflow, and click "Run workflow" to build new images.


Named Volumes and Data Persistence

VolumeContainsCriticality
backend-dataSQLite database (database.db)Critical — back this up
backend-staticUploaded profile images and site imagesImportant — back this up
caddy-dataLet's Encrypt account keys and TLS certificatesImportant — see warning below
caddy-configCaddy auto-generated config cacheSafe to recreate

Never run docker compose down -v in production. The -v flag deletes all named volumes, including the database (backend-data), all uploaded images (backend-static), and Let's Encrypt account keys and certificates (caddy-data). Deleting caddy-data forces Caddy to re-provision certificates from scratch. Let's Encrypt enforces a rate limit of 5 duplicate certificates per registered domain per week — if you hit this limit, your site will be unreachable via HTTPS for up to a week.

Use docker compose down (without -v) when stopping services. Only use -v if you are intentionally wiping all data for a completely fresh start.


Viewing Logs

# Tail all services
docker compose logs -f

# Backend only
docker compose logs -f backend

# Caddy (shows TLS certificate activity)
docker compose logs -f caddy

Stopping Services

# Stop and remove containers (data in named volumes is preserved)
docker compose down

# Stop and remove containers AND delete all named volumes (destroys database and certificates)
# WARNING: do not use in production unless you intend to wipe all data
docker compose down -v

Troubleshooting

Backend container exits before becoming healthy

Check the logs:

docker compose logs backend

Common causes:

  • JWT_SECRET_KEY missing or too short — the backend exits immediately at startup with a ValueError. The key must be at least 64 characters. Generate one with openssl rand -hex 32.
  • JWT_SECRET_KEY is a known weak placeholder — values like secret, changeme, or supersecret are rejected at startup even if they are long enough.
  • DATABASE_PATH points outside the volume — if DATABASE_PATH=./database.db, the database is created in the container's writable layer and lost on recreation. Use DATABASE_PATH=/app/data/database.db.

Wards are not loading, /api/wards returns empty

  • Verify backend/wards.csv exists at /opt/lds-stake-portal/backend/wards.csv.
  • Check the bind mount by running: docker compose exec backend cat /app/wards.csv
  • If the file was missing when the container started, create it and restart: docker compose restart backend

Caddy cannot provision TLS certificates

Check Caddy logs:

docker compose logs caddy

Common causes:

  • DNS not propagated — the A records do not yet resolve to your server IP. Use dig yourstake.org to check. Propagation can take up to 48 hours but is usually under 15 minutes.
  • Port 80 blocked — the HTTP-01 ACME challenge requires port 80 to be publicly reachable. Check that your firewall (ufw status) and any cloud provider security group allow inbound TCP/80.
  • Caddy rate-limited — if you have deleted and recreated the stack multiple times, you may have hit the Let's Encrypt limit of 5 duplicate certificates per domain per week. Wait and retry, or use Let's Encrypt's staging environment for testing.

Frontend returns 502 Bad Gateway

The frontend container is healthy but the backend is not responding. Check:

docker compose ps        # Is backend showing "healthy"?
docker compose logs backend

docker compose pull fails with authentication error

The GHCR packages for this repository are private. Follow Step 7 to authenticate with a GitHub PAT before pulling.

Discord bot fails to start

docker compose logs discordbot

Common causes:

  • DISCORD_TOKEN is missing or invalid.
  • BACKEND_TOKEN in discordbot/.env does not match DISCORD_BOT_TOKEN in backend/.env.
  • The backend container is not yet healthy when the Discord bot starts (it depends on backend: condition: service_healthy).

On this page