Skip to content

Production Deployment

This guide covers deploying Rungu in production with Docker Compose, Traefik as a reverse proxy, and automatic TLS certificates from Let's Encrypt.


Architecture Overview

Rungu is a Rust backend with an embedded SvelteKit SPA, shipped as a single statically-linked binary inside a scratch container. There is no separate frontend server to run — the SPA is embedded at compile time via rust-embed.

                    ┌──────────────────────────────────┐
                    │           Traefik :443            │
                    │  (Let's Encrypt TLS termination)  │
                    └───────────────┬──────────────────┘

                            ┌───────┴───────┐
                            │  Rungu :3000   │
                            │  (single binary│
                            │   + SPA embed) │
                            └───────┬───────┘

                         ┌──────────┴──────────┐
                         │                     │
                   ┌─────┴─────┐        ┌──────┴──────┐
                   │ PostgreSQL │        │  Volume     │
                   │  (rec.)    │        │  (uploads)  │
                   └────────────┘        └─────────────┘

Database options:

BackendWhen to use
PostgreSQL (recommended)Production, multi-user, >100k posts, concurrent write load
SQLiteSmall teams, <100 users, single-instance, simpler backups

SQLite is fully supported for production use. Switch to PostgreSQL if you need higher write concurrency, connection pooling across multiple processes, or managed database hosting.


Prerequisites

  • Docker 24+ with the Compose v2 plugin
  • Docker Compose v2 (docker compose command, not the legacy docker-compose)
  • A domain name with DNS A records pointing to your server's public IP
    • Example: feedback.example.com203.0.113.10
  • Ports 80 and 443 open on the server firewall (Traefik needs both — 80 for the HTTP→HTTPS redirect, 443 for TLS)
  • OAuth provider credentials set up in advance (see OAuth Provider Setup)

Verify prerequisites

bash
docker --version          # Docker version 24+
docker compose version    # Docker Compose version v2.20+
openssl rand -hex 32      # Generate APP_SECRET

Docker Compose Setup

The Compose file below is a complete, self-contained production setup. It runs three services: Traefik (reverse proxy with automatic TLS), PostgreSQL, and Rungu.

yaml
# docker-compose.yml
services:
  # ── Reverse Proxy ──────────────────────────────────────────────────
  traefik:
    image: traefik:v3.2
    container_name: traefik
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      # Dashboard — remove in production or protect with a firewall
      - "8080:8080"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - traefik-letsencrypt:/letsencrypt        # TLS certificate storage
    command:
      # Entrypoints
      - --entrypoints.web.address=:80
      - --entrypoints.websecure.address=:443
      - --entrypoints.traefik.address=:8080
      # HTTP → HTTPS redirect on the web entrypoint
      - --entrypoints.web.http.redirections.entrypoint.to=websecure
      - --entrypoints.web.http.redirections.entrypoint.scheme=https
      # Docker provider — only enable services with traefik.enable=true
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      # Let's Encrypt ACME
      - --certificatesresolvers.letsencrypt.acme.tlschallenge=true
      - --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL:?Set ACME_EMAIL}
      - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
      # Dashboard (protect or disable in production)
      - --api.dashboard=true
      - --api.insecure=true
    networks:
      - rungu-net

  # ── PostgreSQL ─────────────────────────────────────────────────────
  postgres:
    image: postgres:17-alpine
    container_name: rungu-postgres
    restart: unless-stopped
    environment:
      - POSTGRES_USER=${POSTGRES_USER:-rungu}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
      - POSTGRES_DB=${POSTGRES_DB:-rungu}
    volumes:
      - postgres-data:/var/lib/postgresql/data
    networks:
      - rungu-net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-rungu}"]
      interval: 10s
      timeout: 5s
      retries: 5

  # ── Rungu ──────────────────────────────────────────────────────────
  rungu:
    image: ghcr.io/codecoradev/rungu:latest
    container_name: rungu
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    volumes:
      - rungu-uploads:/data/uploads         # Attachment storage
    environment:
      # ── Database (PostgreSQL for production) ──
      - DATABASE_URL=postgres://${POSTGRES_USER:-rungu}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-rungu}

      # ── Server ──
      - RUNGU_LISTEN=0.0.0.0:3000
      - RUNGU_TRUST_PROXY=true
      - RUST_LOG=rungu=info

      # ── Auth / Session ──
      - APP_URL=${APP_URL:?Set APP_URL}
      - APP_SECRET=${APP_SECRET:?Set APP_SECRET}
      - ADMIN_EMAILS=${ADMIN_EMAILS:-}

      # ── CORS & Rate Limiting ──
      - RUNGU_CORS_ORIGINS=${RUNGU_CORS_ORIGINS:-}
      - RUNGU_RATE_LIMIT_PER_MIN=${RUNGU_RATE_LIMIT_PER_MIN:-300}
      - RUNGU_AUTH_RATE_LIMIT_PER_MIN=${RUNGU_AUTH_RATE_LIMIT_PER_MIN:-30}

      # ── File Storage ──
      - RUNGU_STORAGE_DIR=/data/uploads

      # ── Google OAuth (leave empty to disable) ──
      - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
      - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}

      # ── GitHub OAuth (leave empty to disable) ──
      - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-}
      - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-}

      # ── Keycloak OIDC (leave empty to disable) ──
      - KEYCLOAK_URL=${KEYCLOAK_URL:-}
      - KEYCLOAK_REALM=${KEYCLOAK_REALM:-}
      - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-}
      - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-}

      # ── Sentry (optional, leave empty to disable) ──
      - SENTRY_DSN=${SENTRY_DSN:-}
    labels:
      - "traefik.enable=true"
      # Router: HTTPS
      - "traefik.http.routers.rungu.rule=Host(`${DOMAIN}`)"
      - "traefik.http.routers.rungu.entrypoints=websecure"
      - "traefik.http.routers.rungu.tls.certresolver=letsencrypt"
      # Service port inside the container
      - "traefik.http.services.rungu.loadbalancer.server.port=3000"
      # Middleware: trust forwarded headers from Traefik
      - "traefik.http.routers.rungu.middlewares=rungu-headers"
      - "traefik.http.middlewares.rungu-headers.headers.stsecsseconds=31536000"
      - "traefik.http.middlewares.rungu-headers.headers.customframeoptionsvalue=SAMEORIGIN"
    networks:
      - rungu-net

volumes:
  postgres-data:
  rungu-uploads:
  traefik-letsencrypt:

networks:
  rungu-net:

Environment File

Create a .env file in the same directory as your docker-compose.yml:

env
# ── Required ──────────────────────────────────────────────────────────
DOMAIN=feedback.example.com
APP_URL=https://feedback.example.com
APP_SECRET=<output of: openssl rand -hex 32>
ACME_EMAIL=admin@example.com

# ── PostgreSQL ────────────────────────────────────────────────────────
POSTGRES_USER=rungu
POSTGRES_PASSWORD=<strong random password>
POSTGRES_DB=rungu

# ── Auth (optional — leave empty to disable) ──────────────────────────
ADMIN_EMAILS=owner@example.com
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
KEYCLOAK_URL=
KEYCLOAK_REALM=
KEYCLOAK_CLIENT_ID=
KEYCLOAK_CLIENT_SECRET=

# ── Optional tuning ───────────────────────────────────────────────────
# RUNGU_CORS_ORIGINS=https://feedback.example.com,https://staging.example.com
# RUNGU_RATE_LIMIT_PER_MIN=300
# RUNGU_AUTH_RATE_LIMIT_PER_MIN=30

# ── Sentry (optional) ─────────────────────────────────────────────────
# SENTRY_DSN=https://xxx@o123.ingest.sentry.io/456

Never commit .env to version control. Add it to .gitignore. The .env.example in the repository is the canonical reference for all variables.

Using SQLite instead of PostgreSQL

If you prefer SQLite (smaller footprint, simpler backups), replace the DATABASE_URL line and remove the postgres service:

yaml
    environment:
      # Use SQLite instead of PostgreSQL:
      - RUNGU_DB=/data/rungu.db
      # Remove: DATABASE_URL=postgres://...
    volumes:
      - rungu-data:/data            # SQLite DB + uploads
      # Remove: rungu-uploads:/data/uploads

volumes:
  rungu-data:                        # Replaces postgres-data + rungu-uploads

Traefik Configuration

The Traefik settings are embedded directly in the command: list and Docker labels above. Here's what each piece does:

Entrypoints

EntrypointPortPurpose
web80HTTP — immediately redirected to HTTPS
websecure443HTTPS — TLS terminated here, traffic forwarded to Rungu
traefik8080Dashboard API — restrict access or remove in production

HTTP → HTTPS Redirect

All traffic on port 80 is redirected to HTTPS automatically:

--entrypoints.web.http.redirections.entrypoint.to=websecure
--entrypoints.web.http.redirections.entrypoint.scheme=https

Let's Encrypt TLS

Traefik uses the TLS-ALPN-01 challenge to obtain certificates automatically. Certificates are stored in /letsencrypt/acme.json (persisted via the traefik-letsencrypt volume).

  • Set ACME_EMAIL to a valid email — Let's Encrypt sends expiry warnings there.
  • The first certificate request takes a few seconds. During that window, you may see TLS errors in the browser — this is normal.

Rungu Service Labels

yaml
labels:
  - "traefik.enable=true"
  - "traefik.http.routers.rungu.rule=Host(`feedback.example.com`)"
  - "traefik.http.routers.rungu.entrypoints=websecure"
  - "traefik.http.routers.rungu.tls.certresolver=letsencrypt"
  - "traefik.http.services.rungu.loadbalancer.server.port=3000"

Key points:

  • exposedbydefault=false is set globally, so only services with traefik.enable=true are proxied.
  • The Host() rule must match your DOMAIN and APP_URL exactly.
  • The load balancer port is 3000 — the port Rungu listens on inside the container.

Dashboard Security

The Traefik dashboard on :8080 is enabled for convenience. For production, either:

  1. Remove the dashboard entirely — delete --api.dashboard=true, --api.insecure=true, and the 8080 port mapping.
  2. Protect it with Traefik's basic auth middleware:
yaml
labels:
  - "traefik.http.routers.dashboard.rule=Host(`traefik.example.com`)"
  - "traefik.http.routers.dashboard.entrypoints=websecure"
  - "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
  - "traefik.http.routers.dashboard.middlewares=dashboard-auth"
  - "traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$$apr1$$..."

Generate the password hash with: htpasswd -nB admin | sed 's/\$/&/g' (the $$ escaping is required in Compose labels).


Environment Variables

All variables are documented in detail in Configuration. Below is a summary focused on production deployment.

Database

VariableExampleNotes
DATABASE_URLpostgres://rungu:pass@postgres:5432/runguTakes precedence over RUNGU_DB. Use for PostgreSQL.
RUNGU_DB/data/rungu.dbSQLite path. Used when DATABASE_URL is unset.

Application

VariableRequiredExampleNotes
APP_URL✅ Yeshttps://feedback.example.comUsed for OAuth redirect URIs and CORS defaults. Must be https:// in production.
APP_SECRET✅ Yes(64-char hex)JWT signing secret. Generate with openssl rand -hex 32. Never reuse across deployments.
ADMIN_EMAILSRecommendedowner@example.comComma-separated. Without this, no users get admin role.
RUNGU_SECURE_COOKIELeave defaulttrue (default)Keeps Secure flag on session cookies. Don't change in HTTPS production.

CORS & Rate Limiting

VariableDefaultNotes
RUNGU_CORS_ORIGINS(APP_URL only)Comma-separated extra origins. Leave unset to allow only APP_URL. Never use * in production.
RUNGU_RATE_LIMIT_PER_MIN300Max API requests/min per IP. 0 disables.
RUNGU_AUTH_RATE_LIMIT_PER_MIN30Max auth requests/min per IP. 0 disables.
RUNGU_TRUST_PROXYfalseSet to true behind Traefik so rate limiting uses the real client IP from X-Forwarded-For.

OAuth Providers

VariableProviderNotes
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRETGoogleEmpty = disabled
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRETGitHubEmpty = disabled
KEYCLOAK_URL / KEYCLOAK_REALM / KEYCLOAK_CLIENT_ID / KEYCLOAK_CLIENT_SECRETKeycloakAll four required to enable. Empty = disabled.

See OAuth Provider Setup for redirect URI configuration.

Error Monitoring

VariableDefaultNotes
SENTRY_DSN(unset)If set, Rungu sends errors and performance traces to Sentry. If unset, Sentry is a no-op — no events are sent and no network calls are made.

OAuth Provider Setup

Rungu derives redirect URIs automatically from APP_URL. There are no *_REDIRECT_URI overrides.

The pattern is always:

{APP_URL}/auth/{provider}/callback

Google

  1. Go to Google Cloud Console → Credentials

  2. Create an OAuth 2.0 Client ID (Application type: Web application)

  3. Add the authorized redirect URI:

    https://feedback.example.com/auth/google/callback
  4. Set in .env:

    env
    GOOGLE_CLIENT_ID=xxxx.apps.googleusercontent.com
    GOOGLE_CLIENT_SECRET=GOCSPX-xxxx

See Google OAuth Setup for the full guide.

GitHub

  1. Go to GitHub → Settings → Developer settings → OAuth Apps

  2. Create a new OAuth App

  3. Set the Authorization callback URL:

    https://feedback.example.com/auth/github/callback
  4. Set in .env:

    env
    GITHUB_CLIENT_ID=Ov23xxxx
    GITHUB_CLIENT_SECRET=xxxx

See GitHub OAuth Setup for the full guide.

Keycloak

  1. In your Keycloak Admin Console, create a client for Rungu

  2. Set the Valid Redirect URI:

    https://feedback.example.com/auth/keycloak/callback
  3. Set in .env:

    env
    KEYCLOAK_URL=https://auth.example.com
    KEYCLOAK_REALM=myorg
    KEYCLOAK_CLIENT_ID=rungu
    KEYCLOAK_CLIENT_SECRET=xxxx
  4. Ensure your Keycloak realm enforces email verification — Rungu rejects logins with unverified emails.

See Keycloak Setup for the full guide.


First Deployment

bash
# 1. Create the directory and files
mkdir -p /opt/rungu && cd /opt/rungu

# 2. Create docker-compose.yml (copy from above)
# 3. Create .env (copy from above, fill in real values)

# 4. Generate a strong APP_SECRET
echo "APP_SECRET=$(openssl rand -hex 32)"

# 5. Start everything
docker compose up -d

# 6. Watch the logs — Rungu runs migrations on first boot
docker compose logs -f rungu

On first boot, Rungu automatically runs database migrations. You should see a log line like:

INFO rungu: Database migrations complete

Verify the deployment

bash
# Check all services are running
docker compose ps

# Check Rungu health
curl -sf https://feedback.example.com/api/health && echo "OK"

# Check Traefik is proxying
curl -sI https://feedback.example.com | head -1

Backup Strategy

PostgreSQL

Use pg_dump from inside the container:

bash
# Manual backup
docker compose exec postgres pg_dump -U rungu rungu > backup-$(date +%Y%m%d).sql

# Restore
docker compose exec -T postgres psql -U rungu rungu < backup-20250101.sql

Automated daily backup (cron):

bash
# Add to crontab — runs at 3:00 AM daily
0 3 * * * cd /opt/rungu && docker compose exec -T postgres pg_dump -U rungu rungu | gzip > /backups/rungu-$(date +\%Y\%m\%d).sql.gz

Keep at least 7–30 days of backups. Rotate with logrotate or a simple cleanup:

bash
# Delete backups older than 30 days
find /backups -name "rungu-*.sql.gz" -mtime +30 -delete

SQLite

If using SQLite, the database is a single file at /data/rungu.db:

bash
# Safe copy (SQLite handles concurrent reads)
docker compose exec rungu cat /data/rungu.db > backup-$(date +%Y%m%d).db

# Or use the built-in .backup command (safer for WAL mode)
docker compose exec rungu sqlite3 /data/rungu.db ".backup /data/backup.db"

Attachments

Uploaded files are stored in the volume mounted at /data/uploads. Back them up alongside the database:

bash
# Tar the uploads volume
docker compose exec rungu tar czf - /data/uploads > uploads-$(date +%Y%m%d).tar.gz

Updating

bash
cd /opt/rungu

# 1. Pull the latest image
docker compose pull

# 2. Apply changes (recreates containers if the image changed)
docker compose up -d

# 3. Verify
docker compose ps
docker compose logs --tail=50 rungu

Rungu runs migrations automatically on every boot — no manual migration step is needed. Migrations are forward-only and designed to be safe for rolling restarts.

Pinning to a specific version

For reproducibility, pin the image tag instead of using latest:

yaml
services:
  rungu:
    image: ghcr.io/codecoradev/rungu:v0.2.0   # instead of :latest

See GitHub Releases for available tags.

Rollback

If an update causes issues, roll back to the previous version:

bash
# Pin the previous known-good version
sed -i 's|rungu:latest|rungu:v0.1.5|' docker-compose.yml
docker compose up -d

Database migrations are forward-only. If a newer version ran migrations, downgrading may require manually reverting schema changes. Always test updates on a staging environment first.


Troubleshooting

CORS errors in the browser

Symptom: Browser console shows Access-Control-Allow-Origin errors, or API requests fail after login.

Cause: The requesting origin doesn't match APP_URL, and RUNGU_CORS_ORIGINS isn't set or doesn't include the origin.

Fix:

  1. Check that APP_URL matches your domain exactly (including https://, no trailing slash).

  2. If the frontend is served from a different origin, add it:

    env
    RUNGU_CORS_ORIGINS=https://feedback.example.com,https://www.feedback.example.com
  3. Never use RUNGU_CORS_ORIGINS=* in production.

OAuth redirect loop / "redirect_uri_mismatch"

Symptom: Clicking "Login with Google" opens the provider's page but immediately errors or loops back.

Cause: The redirect URI registered in the provider console doesn't match {APP_URL}/auth/{provider}/callback.

Fix:

  1. Check APP_URL in your .env — it must be the production HTTPS URL.

  2. Verify the redirect URI in the provider console matches exactly:

    https://feedback.example.com/auth/google/callback

    No trailing slash, correct protocol (https://), correct domain.

  3. The APP_URL change requires a container restart: docker compose restart rungu.

Symptom: OAuth login succeeds, but the user appears unauthenticated immediately after.

Cause: RUNGU_SECURE_COOKIE=true (the default) sets the Secure flag on the session cookie. If Traefik isn't terminating TLS properly (e.g., the browser connects over HTTP), the cookie is silently dropped.

Fix: Ensure APP_URL starts with https:// and Traefik is handling TLS on port 443. Do not set RUNGU_SECURE_COOKIE=false in production.

Migration failure on startup

Symptom: Rungu container exits immediately after starting. Logs show a migration error.

Fix:

  1. Check the full error:

    bash
    docker compose logs rungu
  2. If using PostgreSQL, verify the database is reachable:

    bash
    docker compose exec postgres pg_isready -U rungu
  3. If the database is unreachable, ensure depends_on with condition: service_healthy is set on the rungu service (it is in the Compose file above).

  4. Never manually edit the migration history table (_sqlx_migrations) unless directed by a maintainer.

Traefik shows 404 for the domain

Symptom: Visiting https://feedback.example.com returns a Traefik 404 page.

Fix:

  1. Verify the Host() rule in Rungu's labels matches your domain:

    yaml
    - "traefik.http.routers.rungu.rule=Host(`feedback.example.com`)"
  2. Check that traefik.enable=true is set on the Rungu service.

  3. Verify Traefik can see the container:

    bash
    docker compose exec traefik wget -qO- http://localhost:8080/api/rawdata | grep rungu

Let's Encrypt certificate fails to issue

Symptom: Browser shows a self-signed or expired certificate, or Traefik logs show ACME errors.

Fix:

  1. Ensure port 443 is reachable from the internet (Let's Encrypt connects to your server for TLS-ALPN-01 challenge).

  2. Verify DNS A record points to the correct IP:

    bash
    dig feedback.example.com +short
  3. Check ACME_EMAIL is set and valid in .env.

  4. Check Traefik logs for ACME errors:

    bash
    docker compose logs traefik | grep -i acme
  5. Rate limits: Let's Encrypt allows 5 duplicate certificates per week. If you hit rate limits during testing, use the staging environment first.

Rate limiting triggers too aggressively

Symptom: API returns 429 Too Many Requests for legitimate users.

Fix: Increase the limits in .env:

env
RUNGU_RATE_LIMIT_PER_MIN=600
RUNGU_AUTH_RATE_LIMIT_PER_MIN=60

Also verify RUNGU_TRUST_PROXY=true so that rate limiting uses the real client IP from Traefik's X-Forwarded-For header, not the proxy's internal IP.