Complete reference for deploying Catalyst with Docker Compose — services, volumes, networking, TLS, health checks, updating, and production hardening.
New to Catalyst? Start with the Quick Start for a 5-minute setup, then come back here for the full reference.
Use this checklist before going to production. Details for each item are in the sections below.
| # | Check | Section |
|---|---|---|
| 1 | PUBLIC_URL is set to your real domain |
Environment |
| 2 | POSTGRES_PASSWORD is a strong, unique password |
Environment |
| 3 | BETTER_AUTH_SECRET was generated with openssl rand -base64 32 |
Environment |
| 4 | NODE_ENV=production when using HTTPS |
TLS / HTTPS |
| 5 | Ports below 1024 are only used if you understand the implications | Networking |
| 6 | BACKEND_PORT and POSTGRES_PORT bound to 127.0.0.1 |
Networking |
| 7 | Backup encryption key is set if using S3 backups | Environment |
| 8 | Auto-updater is enabled with AUTO_UPDATE_AUTO_TRIGGER=false |
Updating |
If you already have Docker Compose installed and just want Catalyst running:
# 1. Get the files
curl -fsSL https://raw.githubusercontent.com/catalystctl/catalyst/main/install.sh | bash
cd catalyst-docker
# 2. Configure
cp .env.example .env
# Edit .env — set PUBLIC_URL and generate secrets
nano .env
# 3. Start
docker compose up -d
# 4. Verify
docker compose ps
curl http://localhost:3000/healthThat's it. Open your PUBLIC_URL in a browser and complete the Setup wizard to create the administrator.
Want more detail? Read the full Installation Guide or continue below for the complete Docker reference.
| Requirement | Minimum | Recommended |
|---|---|---|
| Container runtime | Docker 20.10+ or Podman 4.0+ | Docker 26+ (rootless) |
| Compose | Docker Compose V2 or podman-compose |
Latest stable |
| RAM | 2 GB | 4+ GB |
| Disk | 20 GB | 50+ GB SSD |
| Ports | 80, 3000, 2022 (or custom via .env) |
80, 443, 2022 |
Note: Docker Compose is the only supported deployment method. Direct bare-metal installation is not supported.
The Docker Compose stack (catalyst-docker/docker-compose.yml) defines four core services:
| Service | Image | Purpose | Default Exposed Port |
|---|---|---|---|
postgres |
postgres:16-alpine |
Primary database | 127.0.0.1:5432 |
redis |
redis:7-alpine |
Compose service; not session store in current backend | 127.0.0.1:6379 |
backend |
ghcr.io/catalystctl/catalyst-backend:latest |
Fastify API (SFTP runs on the node agent, not here) | 127.0.0.1:3000 |
frontend |
ghcr.io/catalystctl/catalyst-frontend:latest |
Nginx static SPA | 0.0.0.0:80 |
PostgreSQL stores all persistent data: users, servers, nodes, templates, roles, audit logs, and settings. It is the single source of truth for the entire panel.
Redis provides optional caching, rate limiting, and session storage. If Redis is unavailable, Catalyst falls back to in-memory caching gracefully.
Backend is the heart of Catalyst: Fastify API server, WebSocket gateway for agent communication, SFTP file server, plugin loader, task scheduler, alert service, and webhook dispatcher. It auto-runs database migrations on startup.
Frontend is a static React SPA served by Nginx. It proxies /api/* and /ws requests to the backend. All frontend assets are built at image build time — no runtime compilation needed.
frontend → backend → postgres
↘ redis
The frontend waits for the backend health check to pass before starting. The backend waits for both PostgreSQL and Redis health checks. On first startup, this cascade can take 1–3 minutes.
Catalyst uses five named Docker volumes that persist data across container restarts:
| Volume | Mount Point | Purpose |
|---|---|---|
catalyst-postgres-data |
/var/lib/postgresql/data |
Database files |
catalyst-server-data |
/var/lib/catalyst/servers |
Game server files |
catalyst-backup-data |
/var/lib/catalyst/backups |
Backup archives |
catalyst-plugin-data |
/var/lib/catalyst/plugins |
Plugin files |
caddy-data / traefik-certs |
— | TLS certificates (when using Caddy/Traefik overlays) |
Docker stores volumes in /var/lib/docker/volumes/ (Docker) or ~/.local/share/containers/storage/volumes/ (rootless Podman).
# List Catalyst volumes
docker volume ls | grep catalyst
# Inspect a volume
docker volume inspect catalyst-postgres-dataFull backup script:
#!/bin/bash
BACKUP_DIR="./catalyst-backup-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP_DIR"
# Stop the stack first for a consistent snapshot
docker compose down
# Backup each volume
for vol in catalyst-postgres-data catalyst-server-data catalyst-backup-data catalyst-plugin-data; do
docker run --rm \
-v "${vol}:/source:ro" \
-v "$(pwd)/${BACKUP_DIR}:/backup" \
alpine tar czf "/backup/${vol}.tar.gz" -C /source .
echo "Backed up: ${vol}"
done
echo "Backups saved to: ${BACKUP_DIR}"Restore from backup:
# WARNING: This overwrites current data. Stop the stack first.
docker compose down
# Restore each volume
BACKUP_DIR="./catalyst-backup-20260101-120000"
for vol in catalyst-postgres-data catalyst-server-data catalyst-backup-data catalyst-plugin-data; do
docker run --rm \
-v "${vol}:/target" \
-v "$(pwd)/${BACKUP_DIR}:/backup:ro" \
alpine sh -c "rm -rf /target/* && tar xzf /backup/${vol}.tar.gz -C /target"
echo "Restored: ${vol}"
done
# Restart
docker compose up -dDatabase-only backup (while running):
docker compose exec postgres pg_dump -U catalyst catalyst_db > catalyst-db-$(date +%Y%m%d).sqlDatabase-only restore:
docker compose down
docker compose up -d postgres
sleep 5
docker compose exec -T postgres psql -U catalyst -d catalyst_db < catalyst-db-20260101.sql
docker compose up -d::: danger Destructive This deletes all data including databases, server files, backups, and plugins. This cannot be undone. :::
docker compose down -vTo also remove images:
docker compose down -v --rmi allAll services communicate on an internal Docker bridge network (catalyst_default). No external access is required for postgres and redis — they are only reachable from other containers within the Compose network.
To isolate Catalyst from other containers or use a specific IP range:
# docker-compose.override.yml
networks:
catalyst:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/16Then start with: docker compose -f docker-compose.yml -f docker-compose.override.yml up -d
| Variable | Default | Description |
|---|---|---|
FRONTEND_PORT |
0.0.0.0:8080 (install / .env.example; bare compose fallback is :80) |
Panel access. Use 127.0.0.1:8080 to restrict to localhost. |
BACKEND_PORT |
127.0.0.1:3000 |
API access. Usually localhost-only (proxied by nginx). |
POSTGRES_PORT |
127.0.0.1:5432 |
Database. Disable by commenting out if not needed externally. |
REDIS_PORT |
127.0.0.1:6379 |
Redis. Optional — comment out to disable external access. |
::: tip Rootless Podman
Rootless Podman cannot bind ports below 1024. Use FRONTEND_PORT=0.0.0.0:8080 instead of :80.
:::
Catalyst supports three TLS options. Choose one.
Caddy is the simplest option. One command, automatic Let's Encrypt certificates, HTTP→HTTPS redirect.
Prerequisites:
- DNS A record pointing your domain to your server's public IP
- Ports 80 and 443 reachable from the internet (required for ACME challenge)
Steps:
-
Set your domain in
.env:DOMAIN=panel.example.com ACME_EMAIL=admin@example.com PUBLIC_URL=https://panel.example.com NODE_ENV=production FRONTEND_PORT=127.0.0.1:8080
-
Start with the Caddy overlay:
docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
-
Verify:
docker compose ps curl -I https://panel.example.com
That's it. Caddy handles certificate issuance, renewal, and redirects automatically. Certificates persist in the caddy-data volume.
Traefik offers Docker-native service discovery, a web dashboard, and more routing flexibility.
Prerequisites: Same as Caddy (DNS + ports 80/443).
Steps:
-
Set your domain in
.env:DOMAIN=panel.example.com ACME_EMAIL=admin@example.com PUBLIC_URL=https://panel.example.com NODE_ENV=production FRONTEND_PORT=127.0.0.1:8080
-
Start with the Traefik overlay:
docker compose -f docker-compose.yml -f docker-compose.traefik.yml up -d
-
(Optional) Access the Traefik dashboard:
ssh -L 8080:localhost:8080 your-server # Then open http://localhost:8080 in your browserSecurity: Never expose the Traefik dashboard on
0.0.0.0without authentication. SetTRAEFIK_DASHBOARD_PORT=to disable it entirely.
If you already run Nginx, Apache, HAProxy, or another proxy, the recommended
approach is to proxy everything to the bundled frontend nginx container
and let its internal routing handle /ws, /api/, /auth/, /docs, and
static assets.
Do NOT add a separate
/wsblock in your external proxy. The bundled nginx (frontendcontainer) already has optimized/ws,/api/,/auth/,/docs, and static asset routing with correct buffering, timeouts, and WebSocket upgrade headers. Adding a separate/wslocation in your external proxy bypasses these settings and can cause console streaming failures, truncated responses, and agent disconnections.
If you already run Nginx, Apache, HAProxy, or another proxy:
-
Bind Catalyst to localhost only:
FRONTEND_PORT=127.0.0.1:8080 PUBLIC_URL=https://panel.example.com NODE_ENV=production BACKEND_EXTERNAL_ADDRESS=https://panel.example.com
-
Proxy traffic to
http://localhost:8080:Nginx example:
server { listen 443 ssl http2; server_name panel.example.com; ssl_certificate /etc/letsencrypt/live/panel.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/panel.example.com/privkey.pem; client_max_body_size 0; location / { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # WebSocket support (agent connections, admin event stream) location /ws { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 86400s; proxy_send_timeout 86400s; } } # Redirect HTTP → HTTPS server { listen 80; server_name panel.example.com; return 301 https://$host$request_uri; }
Caddy example:
panel.example.com { reverse_proxy localhost:8080 }
::: warning CORS and Cookies
When using a reverse proxy, ensure PUBLIC_URL exactly matches the URL users access. Mismatches cause CORS errors and cookie rejection. If your proxy adds or removes www., set PUBLIC_URL accordingly.
:::
::: warning HSTS and NODE_ENV
When NODE_ENV=production, the backend sets Strict-Transport-Security (HSTS). Never set NODE_ENV=production with plain HTTP — browsers will cache HSTS and refuse to load http:// resources. Keep NODE_ENV=development when running behind no TLS.
:::
The docker-compose.yml passes environment variables directly to containers. All configuration is driven by the .env file.
When you set PUBLIC_URL, these are automatically derived:
| Derived Variable | Source |
|---|---|
BETTER_AUTH_URL |
PUBLIC_URL |
CORS_ORIGIN |
PUBLIC_URL |
FRONTEND_URL |
PUBLIC_URL |
BACKEND_EXTERNAL_ADDRESS |
PUBLIC_URL |
BACKEND_URL |
PUBLIC_URL |
You only need to override these individually for split internal/external setups (e.g., internal Docker network addresses differ from public URLs).
| Variable | How to Set | Notes |
|---|---|---|
PUBLIC_URL |
.env file |
Required. The exact URL users type into their browser. No trailing slash. |
POSTGRES_PASSWORD |
.env file |
Required. Strong password. No default. |
BETTER_AUTH_SECRET |
.env file |
Required. Generate with openssl rand -base64 32. |
BACKUP_CREDENTIALS_ENCRYPTION_KEY |
.env file |
Required for S3 backups. Generate with openssl rand -hex 32. |
# Better Auth secret (session encryption)
openssl rand -base64 32
# Backup encryption key
openssl rand -hex 32
# Webhook signing secret
openssl rand -hex 32
# API key signing secret
openssl rand -base64 32Each service includes a health check:
| Service | Check | Interval | Failure Action |
|---|---|---|---|
postgres |
pg_isready -U catalyst |
10s | Backend returns 503, frontend won't start |
redis |
redis-cli ping |
10s | Graceful fallback to in-memory cache |
backend |
curl -sf http://localhost:3000/health |
15s | Frontend won't start |
The backend health check also verifies database connectivity. If PostgreSQL is unreachable, the backend returns HTTP 503 and the frontend container will not start.
# Check all services
docker compose ps
# Check backend health
curl http://localhost:3000/health
# Check individual service health
docker compose exec postgres pg_isready -U catalyst
docker compose exec redis redis-cli pingBy default, Catalyst uses latest tags for pre-built images. For production, pin to specific versions for reproducible deployments.
-
Check available versions:
# List available image tags curl -s https://ghcr.io/token?scope=repository:catalystctl/catalyst-backend:pull | jq -r '.token' # Or check the GitHub releases page: https://github.com/catalystctl/catalyst/releases
-
Pin in
.env:BACKEND_IMAGE=ghcr.io/catalystctl/catalyst-backend:v1.2.3 FRONTEND_IMAGE=ghcr.io/catalystctl/catalyst-frontend:v1.2.3
-
Update
docker-compose.ymlto use the variables:services: backend: image: ${BACKEND_IMAGE:-ghcr.io/catalystctl/catalyst-backend:latest} frontend: image: ${FRONTEND_IMAGE:-ghcr.io/catalystctl/catalyst-frontend:latest}
# 1. Check current version
docker compose exec backend cat /app/package.json | grep version
# 2. Update .env with new version
sed -i 's/v1.2.3/v1.2.4/' .env
# 3. Pull and restart
docker compose pull
docker compose up -d
# 4. Verify
docker compose ps
curl http://localhost:3000/healthEnable in .env:
AUTO_UPDATE_ENABLED=true
AUTO_UPDATE_INTERVAL_MS=3600000
AUTO_UPDATE_AUTO_TRIGGER=falseAUTO_UPDATE_AUTO_TRIGGER=false: Backend notifies admins when an update is available. Admin approves via the panel.AUTO_UPDATE_AUTO_TRIGGER=true: Backend automatically pulls new images and restarts the stack. Use with caution.
cd catalyst-docker
# Pull latest images
docker compose pull
# Restart with new images
docker compose up -d
# Verify all services are healthy
docker compose psThe backend entrypoint automatically runs prisma migrate deploy on every startup, so database migrations are applied before the API starts accepting connections.
If an update breaks something, roll back to the previous image version:
# 1. Identify the previous image tag
docker images | grep catalyst-backend
# 2. Update .env to pin the previous version
# BACKEND_IMAGE=ghcr.io/catalystctl/catalyst-backend:v1.2.3
# FRONTEND_IMAGE=ghcr.io/catalystctl/catalyst-frontend:v1.2.3
# 3. Restart with the pinned version
docker compose up -d
# 4. If the database migration failed, you may need to restore from backup
# See the Volume Management section above.Migrations run automatically, but you can verify:
docker compose exec backend npx prisma migrate status --schema prisma/schema.prismaIf migrations are pending but the backend is running, they were likely applied on startup. If you see failures:
# View migration logs
docker compose logs backend | grep -i "migrat"
# Run manually (only if auto-migration failed)
docker compose exec backend pnpm run db:migrateSymptom: docker compose up fails with "address already in use" or containers won't start.
Check:
ss -tlnp | grep -E ':(80|8080|3000|2022|5432|6379)'Fix: Change the conflicting port in .env:
FRONTEND_PORT=0.0.0.0:8081
BACKEND_PORT=127.0.0.1:3001Symptom: PostgreSQL fails to start with "Permission denied" on data directory.
Cause: Docker volumes created with one user ID and accessed by another, or SELinux/AppArmor blocking access.
Fix:
# Recreate the volume with correct permissions
docker compose down
docker volume rm catalyst-postgres-data
docker compose up -d postgres
# For SELinux, add :Z or :z to volume mounts in docker-compose.yml
# volumes:
# - catalyst-postgres-data:/var/lib/postgresql/data:ZSymptom: Various failures when running rootless Podman.
Common fixes:
| Issue | Fix |
|---|---|
| Can't bind port 80 | Use FRONTEND_PORT=0.0.0.0:8080 |
podman compose up hangs |
Normal — it waits for healthchecks. Check podman ps in another terminal. |
| Permission denied on volumes | Rootless Podman maps your UID. Ensure files are owned by your user. |
| Can't access containerd socket | The agent needs root or the containerd group. See the Agent Guide. |
Symptom: docker compose up -d seems to hang. docker compose ps shows containers as starting or unhealthy.
This is normal. On first run:
- PostgreSQL initializes its data directory (~30s)
- Redis starts (~5s)
- Backend waits for PostgreSQL health check (~20s)
- Backend runs Prisma migrations (~30s–2min depending on database size)
- Frontend waits for backend health check (~15s)
Check progress:
docker compose ps
docker compose logs -f backendThe Docker Compose setup uses POSTGRES_PASSWORD, not DATABASE_URL.
| Variable | Used By | You Set It? |
|---|---|---|
POSTGRES_PASSWORD |
Docker Compose → PostgreSQL container | Yes (in .env) |
DATABASE_URL |
Backend code → connect to PostgreSQL | No (auto-built from POSTGRES_PASSWORD inside the container) |
Compose injects DATABASE_URL=postgresql://catalyst:${POSTGRES_PASSWORD}@postgres:5432/catalyst_db into the backend. You do not need to set DATABASE_URL in the Docker Compose .env.
If you're running the backend outside Docker (development), then you do need DATABASE_URL in catalyst-backend/.env.
Critical: the official Postgres image only applies POSTGRES_PASSWORD the first time the data volume is initialized. Changing the value in .env (or re-running install with a newly generated password) while keeping catalyst-postgres-data causes Prisma P1000 — password authentication failed for user "catalyst". Fix by wiping the volume, restoring the original password, or ALTER USER inside Postgres (see troubleshooting).
Symptom: Login works but you get CORS errors, or cookies are rejected, or OAuth callbacks fail.
Cause: PUBLIC_URL doesn't match the URL in your browser's address bar.
Examples:
| Browser URL | Correct PUBLIC_URL |
|---|---|
http://localhost:8080 |
http://localhost:8080 |
http://192.168.1.100:8080 |
http://192.168.1.100:8080 |
https://panel.example.com |
https://panel.example.com |
https://www.example.com |
https://www.example.com |
Fix: Update .env, restart the backend:
# Edit .env
nano .env
# Restart
docker compose restart backendSymptom: Browser refuses to load the panel, or assets fail to load with "mixed content" errors.
Cause: NODE_ENV=production enables HSTS and upgrade-insecure-requests CSP. Browsers cache these and refuse HTTP.
Fix: Set NODE_ENV=development until you have HTTPS configured.
Podman Compose is a drop-in replacement. Key differences from Docker:
-
Port binding: Use ports above 1024 for rootless mode:
FRONTEND_PORT=0.0.0.0:8080
-
Socket path: If running the agent on the same host, adjust the Docker socket path:
volumes: - /run/user/1000/podman/podman.sock:/var/run/docker.sock
-
SFTP: SFTP runs on the node agent (default port 2022), not in this compose stack — no extra env or port mapping needed here.
All other commands are identical — just replace docker with podman.
- Set
NODE_ENV=productionin.env(only with HTTPS) - Change all default passwords and generate strong secrets
- Use TLS (Caddy, Traefik, or external reverse proxy)
- Restrict
BACKEND_PORTandPOSTGRES_PORTto127.0.0.1 - Disable external Redis access (comment out
REDIS_PORT) - Pin image versions instead of using
latest - Set up automated database backups (see Volume Management)
- Configure backup encryption key (
BACKUP_CREDENTIALS_ENCRYPTION_KEY) - Set
COOKIE_SECURE=truewhen behind HTTPS - Configure OAuth providers if using SSO
- Set up monitoring and alerting
- Enable audit log retention (Admin → Security)
- Review and adjust rate limits (Admin → Security)
- Set
PASSKEY_RP_IDto match your domain
| Command | Description |
|---|---|
docker compose up -d |
Start all services |
docker compose up -d --build |
Build and start |
docker compose logs -f |
Tail all logs |
docker compose logs -f backend |
Tail backend logs |
docker compose logs -f --tail=100 backend |
Last 100 lines of backend logs |
docker compose exec backend pnpm run db:seed |
Seed database with sample data |
docker compose exec backend pnpm run db:studio |
Open Prisma Studio |
docker compose exec backend sh |
Shell into backend container |
docker compose exec postgres psql -U catalyst -d catalyst_db |
PostgreSQL CLI |
docker compose down |
Stop services |
docker compose down -v |
Stop and delete volumes |
docker compose ps |
List service status |
docker compose pull |
Update images |
docker system prune -a |
Clean up unused images and volumes |
docker stats |
Live container resource usage |
To build images locally instead of using pre-built ones:
# catalyst-backend/Dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && corepack prepare pnpm@latest --activate && pnpm install
COPY . .
RUN pnpm run build
EXPOSE 3000 2022
CMD ["node", "dist/index.js"]cd catalyst-backend
docker build -t catalyst-backend:local .
# Update docker-compose.yml to use catalyst-backend:local# catalyst-frontend/Dockerfile
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && corepack prepare pnpm@latest --activate && pnpm install
COPY . .
RUN pnpm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80Last updated: 2026-05-11