The repository ships a production-ready docker-compose.yml that runs three services on one box:
| Service | Image | Role |
|---|
api | Built from Dockerfile | FastAPI application (Python 3.11) |
db | postgres:16-alpine | PostgreSQL 16 database with persistent volume |
caddy | caddy:2-alpine | Reverse 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
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.
Install Docker
ssh root@<server-ip>
curl -fsSL https://get.docker.com | sh
Clone the repository
git clone https://github.com/your-org/causeloop-backend.git
cd causeloop-backend
Configure secrets
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
Edit the Caddyfile
Replace api.yourdomain.com in Caddyfile with your actual subdomain.
Start the stack
On first start, db initialises, then api starts once the database health check passes, then caddy begins serving HTTPS. 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. 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.