Skip to content
CelPlume
select · Enteropen · Escclose Powered by Pagefind

Deployment Guide

Docker Compose, single-container, and source deployment; plus database configuration, persistence, environment variables, and troubleshooting.

Complete deployment guide for the multi-user schedule and event management tool, covering three deployment methods.

  • Docker 20.0+
  • Docker Compose 2.0+
  • Available memory: 1GB+
  • Available disk: 2GB+
  • Python 3.10+
  • uv 0.4+
  • Bun 1.0+ (frontend requires Bun; do not use npm/yarn)
  • Available memory: 512MB+
  • Available disk: 1GB+

The simplest one-click deployment path, suitable for production use.

# 1. Clone the project
git clone <repository-url>
cd SDNUChronoSync
# 2. Create data directories
mkdir -p data/{uploads,config,logs}
# 3. Only write the initial template if no config exists; upgrades and restarts must not overwrite existing config
test -f data/config/config.toml || cp backend/config.toml data/config/config.toml
# 4. Only create the environment file on first deployment; an existing .env must be preserved as-is
if [ ! -f .env ]; then
umask 077
cat > .env << EOF
POSTGRES_PASSWORD=$(openssl rand -hex 16)
APP_ENV=production
SECRET_KEY=$(openssl rand -hex 32)
EOF
fi
# 5. Start services (automatically starts PostgreSQL + app)
docker compose up -d
# 6. Check service status
docker compose ps

Key environment variables (required for production)

Section titled “Key environment variables (required for production)”

Production must set SECRET_KEY (shared by JWT and email verification code digests; all workers must agree), and explicitly set APP_ENV=production. Docker Compose passes both values in from .env.

Generate a random SECRET_KEY (either method):

# Method A: openssl
openssl rand -hex 32
# Method B: uv + python
cd backend && uv run python -c "import secrets; print(secrets.token_urlsafe(48))"

Then write it into the environment: block of docker-compose.yml (example):

environment:
- APP_ENV=production
- SECRET_KEY=<paste-your-secret-key-here>
# optional: CORS (comma-separated domains)
# - ALLOWED_ORIGINS=https://your-domain.com,http://localhost:4321

If APP_ENV=production is set but SECRET_KEY is not, the backend refuses to start (to avoid the security risk of a default weak key).

The Docker image’s built-in Nginx has HTTPS + HTTP/2 enabled (single-connection multiplexing). A self-signed certificate is auto-generated by default, so the browser may warn “Not secure” on first visit; for production, mount your own certificates at /etc/nginx/certs/tls.crt and /etc/nginx/certs/tls.key.

On PostgreSQL 18 and later, the actual PGDATA lives at /var/lib/postgresql/<major>/docker, so Compose mounts the named volume at the parent /var/lib/postgresql. Reverting to /var/lib/postgresql/data would leave the real database cluster in an unmanaged anonymous volume.

Compose mounts ./data/logs to /app/logs. Supervisor reopens existing log files in append mode, so container restarts never truncate old logs; upgrades and migrations must not delete or re-initialize this directory.

# View logs
docker compose logs -f
# Stop services
docker compose down
# Rebuild and start
docker compose up -d --build
# Enter the container
docker compose exec sdnu-chronosync bash
# Back up the PostgreSQL database
mkdir -p backups
docker compose exec db pg_dump -U chronosync -d chronosync -Fc > "backups/chronosync_$(date +%Y%m%d_%H%M%S)_$$.dump"

The sole source of truth for the production schema is scripts/migrations/alembic/. The Docker entrypoint runs uv run alembic -c /app/alembic.ini upgrade head before starting Supervisor; at startup the app re-verifies that alembic_version equals the live head. The PostgreSQL path never calls Base.metadata.create_all() to patch or upgrade tables — if a revision is missing or mismatched, the app refuses to start.

For source deployments or manual upgrades during a maintenance window:

cd backend
export DATABASE_URL='postgresql+psycopg://chronosync:密码@localhost:5432/chronosync'
uv run alembic -c alembic.ini upgrade head
uv run alembic -c alembic.ini current
uv run alembic -c alembic.ini heads

current and heads must report the same revision. For a fresh empty database, run upgrade head directly. If the old PostgreSQL instance was created by an early create_all, business tables exist but there is no alembic_version, do not start the app or manually stamp head; you must run the restricted bootstrap after stopping the service and taking a backup:

cd backend
uv run python ../scripts/migrations/bootstrap_postgres.py --database-url "$DATABASE_URL"

The bootstrap only accepts the c4d5e6f7a8b9 catalog baseline declared in code (complete tables, columns, primary keys, foreign keys, and required indexes), stamps to that explicit revision, then transactionally upgrades to head; any incomplete object is rejected — it cannot be used to bypass arbitrary old schemas.

VariableDescriptionExample
POSTGRES_PASSWORDPostgreSQL password (required)generate with openssl rand -hex 16
DATABASE_URLSQLAlchemy connection stringpostgresql+psycopg://chronosync:密码@db:5432/chronosync
DB_POOL_SIZEConnection pool size (default 10)10
DB_MAX_OVERFLOWOverflow connections (default 20)20
DB_POOL_RECYCLEConnection recycle seconds (default 1800)1800

The per-process business connection cap is DB_POOL_SIZE + DB_MAX_OVERFLOW, plus one independent health-check connection; multi-process deployments must size PostgreSQL max_connections accounting for one probe connection per worker.

The health check uses an independent single-connection pool; both pool wait and database connect timeouts are 2 seconds, so when the business pool is exhausted the probe returns not-ready immediately instead of waiting on DB_POOL_TIMEOUT.

Local development can continue to use SQLite; the app keeps Base.metadata.create_all() on the SQLite path only, to create missing objects. It does not replace field upgrades on old production databases.

cd backend
# default DATABASE_URL=sqlite:///./schedule_app.db
uv run python main.py

An already-deployed legacy SQLite database must run a dedicated upgrader while stopped before switching to PostgreSQL; do not rely on starting the app to implicitly fix the database.

The full command list, verification checkpoints, and rollback steps are maintained only in the Migration Runbook. This must be executed during a downtime maintenance window; no application process may write to the source or target database while it runs. Preserve the original SQLite database and old container, upgrade only the working copy, and import only into a fresh empty PostgreSQL.

Suitable for simple deployment scenarios where you manage data persistence manually.

The Docker image build freezes frontend and backend dependencies via backend/uv.lock and frontend/bun.lock, running uv sync --frozen and bun install --frozen-lockfile respectively. .dockerignore excludes databases, environment files, virtual environments, upload directories, and caches so production data never ends up in the image.

# 1. Build the image
docker build -t sdnu-chronosync:latest .
# 2. Prepare data directories and config files
mkdir -p ~/sdnu-data/{uploads,config,logs}
test -f ~/sdnu-data/config/config.toml || cp backend/config.toml ~/sdnu-data/config/config.toml
# 3. Run the container (requires an external PostgreSQL already running)
docker run -d \
--name sdnu-chronosync \
-p 1145:1145 \
-e APP_ENV=production \
-e SECRET_KEY='<replace-with-strong-random-string>' \
-e DATABASE_URL='postgresql+psycopg://chronosync:password@host.docker.internal:5432/chronosync' \
-e ALLOWED_ORIGINS='https://your-domain.com' \
-v ~/sdnu-data/uploads:/app/uploads \
-v ~/sdnu-data/config/config.toml:/app/config.toml:ro \
-v ~/sdnu-data/logs:/app/logs \
--restart unless-stopped \
sdnu-chronosync:latest

Note: If you need to edit “System Settings / Code Injection” through the admin panel, change -v ~/sdnu-data/config/config.toml:/app/config.toml:ro to a writable mount (remove :ro).

Important: The external PostgreSQL data is persisted by the database service itself; the app container only bind-mounts the upload directory, config.toml, and the log directory. Make sure ~/sdnu-data/config/config.toml exists before starting the container.

# Check container status
docker ps
# View logs
docker logs sdnu-chronosync -f
# Stop the container
docker stop sdnu-chronosync
# Start the container
docker start sdnu-chronosync
# Delete the container
docker rm sdnu-chronosync
# Back up the PostgreSQL database (requires connection to the PostgreSQL container)
docker exec sdnu-chronosync-db pg_dump -U chronosync -d chronosync -Fc > "backup_$(date +%Y%m%d_%H%M%S)_$$.dump"

Suitable for development environments or scenarios that require customization.

# 0. Install uv (https://docs.astral.sh/uv/getting-started/installation/)
# 1. Install Python dependencies
cd backend
uv sync
# 2. Install frontend dependencies (bun only)
cd ../frontend
bun install
# 3. Build the frontend
bun run build
Section titled “Method 1: Using the start script (recommended)”
# From the project root
chmod +x scripts/start_dev.sh
./scripts/start_dev.sh
# Terminal 1: start the backend (port 8000)
cd backend
uv run python main.py
# Optional: enable HTTPS + HTTP/2 on the backend port itself (requires TLS; a self-signed cert is auto-generated by default)
# then visit https://localhost:8000
# ENABLE_HTTP2=1 uv run python main.py
# Terminal 2: start the frontend (port 4321)
cd frontend
bun run dev
# Terminal 3: use nginx as a reverse proxy to port 1145 (optional)
# configure nginx.conf to forward requests to the corresponding service
# 1. Build the frontend
cd frontend
bun run build
# 2. Configure the production server (nginx + uvicorn)
# nginx config example:
server {
listen 443 ssl http2;
server_name your-domain.com;
ssl_certificate /etc/nginx/certs/tls.crt;
ssl_certificate_key /etc/nginx/certs/tls.key;
location / {
root /path/to/frontend/dist;
try_files $uri $uri/ @backend;
}
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location @backend {
proxy_pass http://127.0.0.1:8000;
}
}
# 3. Start the backend with uv
cd backend
uv run uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4

Containerized deployment must persist important data to the host so that it survives container restarts.

The application files recommend the following bind mounts; PostgreSQL data does not live in an SQLite file under this directory but is persisted by the postgres_data named volume:

~/sdnu-data/
├── uploads/
│ └── avatars/
├── config/
│ └── config.toml
└── logs/
├── fastapi.log
└── nginx.log

You must create config.toml first, or Docker may create the target path as a directory.

1. Docker Compose persistence (auto-configured)

Section titled “1. Docker Compose persistence (auto-configured)”

The current docker-compose.yml persistence mapping is:

services:
db:
volumes:
- postgres_data:/var/lib/postgresql
sdnu-chronosync:
volumes:
- ./data/uploads:/app/uploads
- ./data/config/config.toml:/app/config.toml:ro
- ./data/logs:/app/logs
volumes:
postgres_data:

On PostgreSQL 18 and later, the actual PGDATA lives at /var/lib/postgresql/<major>/docker, so the named volume must mount the parent /var/lib/postgresql. If you want to save site config or code injection through the admin panel, remove the :ro from the config.toml mount; otherwise edit it directly on the host and restart the container.

2. Docker single-container persistence (manual configuration)

Section titled “2. Docker single-container persistence (manual configuration)”
# Create host directories
mkdir -p ~/sdnu-data/{database,uploads,config,logs}
# Only copy the initial config on first deployment; existing files must not be overwritten
test -f ~/sdnu-data/config/config.toml || cp backend/config.toml ~/sdnu-data/config/config.toml
# Run the container and mount directories (specify the database path via environment variable)
docker run -d \
--name sdnu-chronosync \
-p 1145:1145 \
-e DATABASE_URL=sqlite:////app/data/schedule_app.db \
-v ~/sdnu-data/database:/app/data \
-v ~/sdnu-data/uploads:/app/uploads \
-v ~/sdnu-data/config/config.toml:/app/config.toml:ro \
-v ~/sdnu-data/logs:/app/logs \
--restart unless-stopped \
sdnu-chronosync:latest

Mount notes:

  • ~/sdnu-data/database:/app/data — database file directory (must be the database subdirectory)
  • ~/sdnu-data/uploads:/app/uploads — user upload directory
  • ~/sdnu-data/config/config.toml:/app/config.toml:ro — config file (read-only)
  • ~/sdnu-data/logs:/app/logs — application log directory

PostgreSQL is backed up with pg_dump, which supports hot backups (no downtime):

# Create the backup script
cat > backup.sh << 'EOF'
#!/bin/bash
set -euo pipefail
RUN_ID="$(date +%Y%m%d_%H%M%S)_$$"
BACKUP_DIR="./backups/$RUN_ID"
mkdir -p "$BACKUP_DIR"
# Back up PostgreSQL (custom format, supports selective restore)
docker compose exec -T db pg_dump -U chronosync -d chronosync -Fc > "$BACKUP_DIR/chronosync.dump"
# Preserve uploads, config, env vars, and runtime logs; each run writes to its own directory.
cp -a ./data/uploads "$BACKUP_DIR/uploads"
cp -a ./data/config "$BACKUP_DIR/config"
cp -a ./data/logs "$BACKUP_DIR/logs"
if [ -f ./.env ]; then
cp -a ./.env "$BACKUP_DIR/.env"
fi
# Old backups are not auto-deleted; define a retention policy based on actual storage capacity.
echo "Backup complete: $BACKUP_DIR"
EOF
chmod +x backup.sh
# Schedule periodic backups (optional)
# crontab -e
# 0 2 * * * /path/to/backup.sh

Restoring a backup:

# Restore a PostgreSQL backup
docker compose exec db pg_restore -U chronosync -d chronosync backups/xxx/chronosync.dump
# Or restore to a new database
docker compose exec db createdb -U chronosync chronosync_restore
docker compose exec db pg_restore -U chronosync -d chronosync_restore backups/xxx/chronosync.dump
Directory / FilePurposeMust persist?
PostgreSQL named volume (postgres_data)PostgreSQL data filesMust preserve
.envDatabase password, unified SECRET_KEY, and other environment variablesMust preserve and never regenerate
/app/uploads/avatars/User avatar filesMust preserve original mount
/app/config.tomlApplication config fileMust preserve original file
/app/logs/Application and Supervisor logsMust preserve original directory; restarts append, old logs are not cleared

Main config file: config.toml

[storage]
provider = "local" # or "alist"
[storage.local]
upload_path = "uploads/avatars"
base_url = "/static/avatars"
[storage.alist]
version = 3
url = "https://your-alist-instance.com"
upload_path = "your-upload-path"
token = "your-token"
username = "your-username"
password = "your-password"
# .env file example
NODE_ENV=production
PYTHONUNBUFFERED=1
# Strongly recommended for production (used to sign login tokens)
APP_ENV=production
SECRET_KEY=<a long random string>
# PostgreSQL password (required, used by Docker Compose)
POSTGRES_PASSWORD=<a long random password>
# Optional: database config (defaults to the PostgreSQL in Docker Compose)
# DATABASE_URL=postgresql+psycopg://chronosync:password@db:5432/chronosync
# Optional: connection pool parameters
# DB_POOL_SIZE=10
# DB_MAX_OVERFLOW=20
# DB_POOL_RECYCLE=1800
# Optional: CORS (comma-separated domains)
# ALLOWED_ORIGINS=https://your-domain.com,http://localhost:4321
# Optional: admin initialization behavior
# AUTO_CREATE_ADMIN=1
# SHOW_INITIAL_ADMIN_PASSWORD=1
# DEFAULT_ADMIN_PASSWORD=<set only if you know what you are doing>
# CREATE_SAMPLE_USERS=0
# Optional: external storage config
# STORAGE_PROVIDER=alist
# ALIST_URL=https://your-alist.com
# ALIST_TOKEN=your-token
# Optional: allowed external hosts for controlled code injection (comma-separated domains)
# CODE_INJECTION_ALLOWED_HOSTS=analytics.hxcn.dev,hm.baidu.com,www.googletagmanager.com

When the browser reports Access to fetch ... has been blocked by CORS policy, add the frontend’s actual Origin to ALLOWED_ORIGINS, comma-separated for multiple sources. This value is an environment variable, not configured in config.toml; you must restart the backend or rebuild the container after changing it.

# Local development: backend/.env
ALLOWED_ORIGINS=http://localhost:4321
# Docker single-container
docker run ... \
-e ALLOWED_ORIGINS='https://your-domain.com,http://localhost:4321' \
...

Docker Compose configures it in the app service’s environment::

environment:
- ALLOWED_ORIGINS=https://your-domain.com,http://localhost:4321

Then run docker compose up -d. CORS only accepts full Origins (protocol, host, and port); do not include page paths, and do not loosen it to any origin for development convenience.

Backend stability and performance parameters

Section titled “Backend stability and performance parameters”
VariableDefaultDescription
DB_POOL_SIZE10Per-worker steady-state PostgreSQL business connections
DB_MAX_OVERFLOW20Per-worker temporary overflow connections
DB_POOL_TIMEOUT30Business pool wait seconds
DB_POOL_RECYCLE1800PostgreSQL connection recycle seconds
AUTH_RATE_LIMIT_WINDOW_SECONDS300Login failure counting window
AUTH_RATE_LIMIT_MAX_ATTEMPTS8Max login failures per window
AUTH_RATE_LIMIT_LOCKOUT_SECONDS600Login lockout seconds
AUTH_RATE_LIMIT_CLEANUP_INTERVAL_SECONDS300Expired rate-limit record cleanup interval
REGISTER_RATE_LIMIT_MAX_ATTEMPTS10Max registration attempts per IP per window
REGISTER_RATE_LIMIT_WINDOW_SECONDS600Registration IP counting window
JWXT_HTTP_TIMEOUT_GET10Academic affairs GET request timeout seconds
JWXT_HTTP_TIMEOUT_POST15Academic affairs POST request timeout seconds
JWXT_MAX_CONCURRENCY3Max concurrency for classroom-availability upstream requests
CLASSROOM_SESSION_TTL_SECONDS600Classroom-availability auth session lifetime
CLASSROOM_SESSION_MAX_ITEMS256Per-process classroom-availability session capacity
CLASSROOM_SESSION_CLEANUP_INTERVAL_SECONDS60Session cleanup interval

Login and registration rate limiting and email verification codes use shared database state, so multiple workers share them correctly. All workers must still use the same stable SECRET_KEY, otherwise JWTs and verification-code HMACs cannot be verified across processes. Each worker’s business connection cap is DB_POOL_SIZE + DB_MAX_OVERFLOW, plus one independent health-probe connection; account for this against PostgreSQL max_connections before adding workers.

Academic affairs connection addresses:

# The on-campus direct upstream only supports HTTP; do not force HTTPS.
JWXT_DIRECT_BASE_URL=http://jwxt.sdnu.edu.cn/jwglxt
JWXT_WEBVPN_BASE_URL=https://webvpn.sdnu.edu.cn:10443/http/<token>/jwglxt
JWXT_WEBVPN_SSO_LOGIN_URL=https://webvpn.sdnu.edu.cn/enlink/sso/login

WebVPN and academic affairs system accounts can differ; credentials are entered by the user each time and must not be written to environment variables, config files, or logs.

🔐 Controlled code injection (analytics scripts / stylesheets / meta)

Section titled “🔐 Controlled code injection (analytics scripts / stylesheets / meta)”

This project supports injecting third-party analytics or style resources into the page head / body, but to avoid XSS risk, “code injection” has been narrowed to a controlled whitelist mechanism.

  • Only script[src] / link[rel=stylesheet,href] / meta[name|property,content] are allowed
  • Inline scripts and event attributes (e.g. onclick) are forbidden
  • Same-origin scripts are limited to /assets/*.js; same-origin styles are limited to /assets/*.css
  • External resources must be https:// and the domain must be in CODE_INJECTION_ALLOWED_HOSTS

How to configure the external-host whitelist (CODE_INJECTION_ALLOWED_HOSTS)

Section titled “How to configure the external-host whitelist (CODE_INJECTION_ALLOWED_HOSTS)”
  • Source deployment / direct run
    • Preferred: add to backend/.env:
      • CODE_INJECTION_ALLOWED_HOSTS=analytics.hxcn.dev
    • Or set a system environment variable before starting the backend.
  • systemd
    • Set Environment="CODE_INJECTION_ALLOWED_HOSTS=analytics.hxcn.dev" in the unit, or use EnvironmentFile=; after changing, run systemctl daemon-reload and restart the service.
  • Docker Compose deployment
    • Add to the environment: block of docker-compose.yml:
      • CODE_INJECTION_ALLOWED_HOSTS=analytics.hxcn.dev
  • Docker single-container deployment
    • Add at docker run time:
      • -e CODE_INJECTION_ALLOWED_HOSTS=analytics.hxcn.dev

If you allow external analytics scripts, the reverse proxy layer must allow them too:

  • script-src / script-src-elem: allow the script domains
  • connect-src: allow the analytics reporting domains (many analytics tools report via fetch)

The Nginx config used by Docker deployment is at docker/nginx.conf; if you change analytics domains, update the CSP at the same time.

The system auto-creates an admin account when no student_id=admin exists in the database:

  • Username: admin
  • Password: randomly generated at first startup, printed only once in the initialization logs
  • Role: admin

Test student accounts are not created by default. Sample users are only created when CREATE_SAMPLE_USERS=1 is explicitly set; do not enable this in production.

When the system detects that no admin account exists in the database, it auto-creates the student_id=admin admin account and prints the password once in the backend startup logs:

[SECURITY] Password: <random_password>

Optional environment variables:

  • DEFAULT_ADMIN_PASSWORD: specify the initial admin password (a fixed weak password is not recommended in production)
  • SHOW_INITIAL_ADMIN_PASSWORD=0: suppress printing the initial password in logs (prints <hidden> instead)
  • AUTO_CREATE_ADMIN=0: disable auto-creating the admin (not recommended — may lock you out of the admin panel)

Check the container logs immediately after first startup and find the [SECURITY] line:

docker compose logs --no-color sdnu-chronosync | grep "\[SECURITY\]"

If you start the backend directly with uv run python main.py / uv run uvicorn, the initial password prints to the current terminal. Save it at first startup.

If you host the backend with systemd, use:

journalctl -u <your-service-name> -b --no-pager | grep "\[SECURITY\]"

Security reminder: this initial password is printed only once; change the admin password immediately after first login.

# Check port conflicts
netstat -tulpn | grep 1145
# Check container logs
docker compose logs sdnu-chronosync
# Check resource usage
docker stats

2. Database connection or revision check failure

Section titled “2. Database connection or revision check failure”
# Check PostgreSQL container status and logs
docker compose ps db
docker compose logs db
docker compose exec db pg_isready -U chronosync -d chronosync
# Check the Alembic current/head as seen by the app image
docker compose run --rm --entrypoint uv sdnu-chronosync \
run alembic -c /app/alembic.ini current
docker compose run --rm --entrypoint uv sdnu-chronosync \
run alembic -c /app/alembic.ini heads
# Only run the upgrade on a fresh empty database or one already on the live chain
docker compose run --rm --entrypoint uv sdnu-chronosync \
run alembic -c /app/alembic.ini upgrade head

PostgreSQL forbids bypassing the version gate with Base.metadata.create_all() or legacy fix scripts. When business tables exist but there is no alembic_version, follow the restricted bootstrap section under PostgreSQL above; when switching from SQLite to PostgreSQL, follow the Migration Runbook for a fresh cutover.

Common mistake: database and config file not taking effect

If the database or config.toml is not loaded as expected, check:

# 1. Confirm the PostgreSQL container is running
docker compose ps db
# 2. Verify the env file and required variables without echoing secrets
test -f .env
set -a
. ./.env
set +a
test -n "${POSTGRES_PASSWORD:-}"
test -n "${SECRET_KEY:-}"
# 3. Confirm the config file exists; only copy the template if missing
test -f /path/to/sdnu-data/config/config.toml || \
cp backend/config.toml /path/to/sdnu-data/config/config.toml
# 4. Restart the container for changes to take effect
docker compose restart
# Check upload directory permissions
ls -la data/uploads/
# Fix permissions
chmod -R 755 data/uploads/
# Rebuild the image
docker compose up -d --build
# Check the nginx config
docker compose exec sdnu-chronosync nginx -t

5. Access returns 502, logs report a --log-config path that does not exist

Section titled “5. Access returns 502, logs report a --log-config path that does not exist”

An old image’s supervisord.conf may reference a non-existent Uvicorn log config. Prefer rebuilding the image from current source so the code, uvicorn_log_config.json, and Supervisor config stay consistent:

docker compose up -d --build

Only when you truly cannot rebuild the old image, mount the corrected Supervisor config at /etc/supervisor/conf.d/supervisord.conf:ro. Once you have switched to the current image in production, remove that compatibility mount to prevent the old config from overriding the in-image config indefinitely.

# Application logs
docker compose logs -f sdnu-chronosync
# Backend API logs
docker compose exec sdnu-chronosync tail -f logs/fastapi.log
# Nginx logs
docker compose exec sdnu-chronosync tail -f logs/nginx.log
# System monitoring
docker compose exec sdnu-chronosync top

The default single worker is sufficient for light deployments. Before increasing Uvicorn workers, you must account for PostgreSQL connections and memory, and ensure all workers use the same SECRET_KEY:

# docker/supervisord.conf example
command=uv run uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4

Each worker uses at most DB_POOL_SIZE + DB_MAX_OVERFLOW business connections, plus one independent health-probe connection. The pool is tunable via environment variables:

DB_POOL_SIZE=10
DB_MAX_OVERFLOW=20
DB_POOL_TIMEOUT=30
DB_POOL_RECYCLE=1800

The health-probe connection pool is fixed at a single connection with a 2-second pool-wait and connect timeout, used to return not-ready quickly when the business pool is exhausted. Adjust PostgreSQL shared_buffers, effective_cache_size, and work_mem based on actual memory and query-load measurements rather than fixed template values.

If you run into issues, check:

  1. Whether system resources are sufficient
  2. Whether the port is already in use
  3. Whether the data directory permissions are correct
  4. Whether the firewall is blocking access

For more help, see the project docs or file an Issue.


🎉 Once deployment is complete, visit https://localhost:1145 to get started!