Skip to main content
The repository ships a production-ready docker-compose.yml that runs three services on one box:
ServiceImageRole
apiBuilt from DockerfileFastAPI application (Python 3.11)
dbpostgres:16-alpinePostgreSQL 16 database with persistent volume
caddycaddy:2-alpineReverse proxy — automatic TLS via Let’s Encrypt
This single-box setup is suitable for most early-stage and growth-stage deployments. For high-availability requirements, move Postgres to a managed provider (Neon, RDS, Cloud SQL) and deploy the API container to a managed compute platform.

Dockerfile

The image uses a two-stage build to minimize the final image size. The runtime stage runs as a non-root user (appuser) for security.
# ── build stage ──────────────────────────────────────────────────────────────
FROM python:3.11-slim AS build
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip \
 && pip install --no-cache-dir -r requirements.txt

# ── runtime stage ─────────────────────────────────────────────────────────────
FROM python:3.11-slim AS runtime
WORKDIR /app
COPY --from=build /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=build /usr/local/bin /usr/local/bin
COPY app/ ./app/

RUN adduser --disabled-password --gecos "" appuser
USER appuser

ENV PORT=4000
EXPOSE 4000

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:${PORT}/health')"

CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT} --workers 2"]
Build and run the image standalone:
# Build
docker build -t causeloop-backend:latest .

# Run with .env file
docker run --rm -p 4000:4000 --env-file .env causeloop-backend:latest

# Run in mock LLM mode (no .env needed)
make docker-run-mock

docker-compose.yml

services:
  api:
    build: .
    restart: unless-stopped
    env_file: .env
    environment:
      PORT: 4000
      DATABASE_URL: postgresql://causeloop:${POSTGRES_PASSWORD}@db:5432/causeloop
    depends_on:
      db:
        condition: service_healthy
    expose:
      - "4000"
    healthcheck:
      test: ["CMD", "python", "-c",
             "import urllib.request; urllib.request.urlopen('http://localhost:4000/health')"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: causeloop
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: causeloop
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U causeloop"]
      interval: 10s
      timeout: 5s
      retries: 5

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - api

volumes:
  pgdata:
  caddy_data:
  caddy_config:

Caddyfile

Edit Caddyfile and replace api.yourdomain.com with your real subdomain before starting:
# Caddy automatically provisions and renews a free Let's Encrypt TLS cert.
api.yourdomain.com {
    reverse_proxy api:4000

    header {
        Strict-Transport-Security "max-age=31536000;"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
        -Server
    }

    encode gzip
}
Caddy handles certificate provisioning and renewal automatically — no certbot, no cron jobs.

Deploying the stack

1

Provision your server and point DNS

Create an Ubuntu 22.04 / Debian 12 VM (Hetzner CX22 is ~€5/month). Add an A record pointing api.yourdomain.com to the server’s public IP. Let the record propagate before starting Caddy.
2

Install Docker

ssh root@<server-ip>
curl -fsSL https://get.docker.com | sh
3

Clone the repository

git clone https://github.com/your-org/causeloop-backend.git
cd causeloop-backend
4

Configure secrets

cp .env.example .env
Edit .env — at minimum set:
JWT_SECRET=<strong-random-string>
CORS_ORIGINS=https://app.yourdomain.com
POSTGRES_PASSWORD=<strong-random-password>
CAUSELOOP_MASTER_KEY=<base64-32-bytes>
# Optional — add LLM keys for real AI responses:
# ANTHROPIC_API_KEY=sk-ant-...
Generate secrets:
python3 -c "import secrets; print(secrets.token_urlsafe(48))"  # JWT_SECRET
python3 -c "import os,base64; print(base64.b64encode(os.urandom(32)).decode())"  # CAUSELOOP_MASTER_KEY
5

Edit the Caddyfile

Replace api.yourdomain.com in Caddyfile with your actual subdomain.
6

Start the stack

docker compose up -d
On first start, db initialises, then api starts once the database health check passes, then caddy begins serving HTTPS.
7

Load the database schema

After the stack is running, load the schema into the Postgres container:
docker compose exec db psql -U causeloop -d causeloop -f /dev/stdin < db/schema.sql
docker compose exec db psql -U causeloop -d causeloop -f /dev/stdin < db/seed_reference.sql
docker compose exec db psql -U causeloop -d causeloop -f /dev/stdin < db/onboard_client.sql
Then apply migrations:
DATABASE_URL=postgresql://causeloop:${POSTGRES_PASSWORD}@localhost:5432/causeloop make migrate
See Database setup for the full guide, including the required causeloop_app role.
8

Verify

curl https://api.yourdomain.com/health
# → {"status":"ok"}
The db service in docker-compose.yml runs as POSTGRES_USER=causeloop, which is the Postgres superuser for that database. Row-Level Security is bypassed by superusers. Before using the database with real tenant data, create a dedicated causeloop_app role with NOSUPERUSER / NOBYPASSRLS and set DATABASE_URL to connect as that role. See Database setup — RLS two-role model.

Kubernetes

The Docker image works on any Kubernetes cluster. Use the /health and /ready endpoints as probes:
livenessProbe:
  httpGet:
    path: /health
    port: 4000
  initialDelaySeconds: 10
readinessProbe:
  httpGet:
    path: /ready
    port: 4000
  initialDelaySeconds: 5
Push the image to your registry:
docker build -t causeloop-backend:latest .
docker tag causeloop-backend:latest your-registry/causeloop-backend:latest
docker push your-registry/causeloop-backend:latest
Supply environment variables via a Secret (for JWT_SECRET, CAUSELOOP_MASTER_KEY, DATABASE_URL) and a ConfigMap (for PORT, CORS_ORIGINS, LLM_PROVIDER_ORDER).

Updating

git pull
docker compose build api
docker compose up -d api
Caddy and Postgres do not need to be restarted for application updates.