---
title: Deployment Guide
description: 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.

## 📋 Table of Contents

- [System requirements](#system-requirements)
- [Deployment methods](#deployment-methods)
  - [1. Docker Compose (recommended)](#1-docker-compose-recommended)
  - [2. Docker single-container](#2-docker-single-container)
  - [3. Source deployment (bun + uv)](#3-source-deployment-bun--uv)
- [Database configuration](#database-configuration)
  - [PostgreSQL (recommended)](#postgresql-recommended)
  - [SQLite (development only)](#sqlite-development-only)
  - [Migrating from SQLite to PostgreSQL](#migrating-from-sqlite-to-postgresql)
- [Container persistence](#container-persistence)
- [Environment configuration](#environment-configuration)
- [Troubleshooting](#troubleshooting)

## 📦 System requirements

### Docker deployment
- Docker 20.0+
- Docker Compose 2.0+
- Available memory: 1GB+
- Available disk: 2GB+

### Source deployment
- Python 3.10+
- uv 0.4+
- Bun 1.0+ (frontend requires Bun; do not use npm/yarn)
- Available memory: 512MB+
- Available disk: 1GB+

## 🚀 Deployment methods

### 1. Docker Compose (recommended)

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

#### Quick start

```bash
# 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)

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):

```bash
# 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):

```yaml
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).

#### Service access

- **App URL**: https://localhost:1145
- **API docs**: https://localhost:1145/docs
- **Health check**: https://localhost:1145/health

> 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.

#### Management commands

```bash
# 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"
```

## 💾 Database configuration

### PostgreSQL (recommended)

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:

```bash
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:

```bash
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.

#### Environment variables

| Variable | Description | Example |
|---|---|---|
| `POSTGRES_PASSWORD` | PostgreSQL password (required) | generate with `openssl rand -hex 16` |
| `DATABASE_URL` | SQLAlchemy connection string | `postgresql+psycopg://chronosync:密码@db:5432/chronosync` |
| `DB_POOL_SIZE` | Connection pool size (default 10) | `10` |
| `DB_MAX_OVERFLOW` | Overflow connections (default 20) | `20` |
| `DB_POOL_RECYCLE` | Connection 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`.

### SQLite (development only)

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.

```bash
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.

### Migrating from SQLite to PostgreSQL

The full command list, verification checkpoints, and rollback steps are maintained only in the [Migration Runbook](https://github.com/CelPlume/SDNUChronoSync/blob/main/scripts/migrations/README_PG.md). 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.

### 2. Docker single-container

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

#### Building the image

> 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.

```bash
# 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.

#### Management commands

```bash
# 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"
```

### 3. Source deployment (bun + uv)

Suitable for development environments or scenarios that require customization.

#### Environment preparation

```bash
# 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
```

#### Starting services

##### Method 1: Using the start script (recommended)

```bash
# From the project root
chmod +x scripts/start_dev.sh
./scripts/start_dev.sh
```

##### Method 2: Manual start

```bash
# 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
```

#### Production deployment

```bash
# 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
```

## 💾 Container persistence

### Data persistence strategy

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

### Host directory structure

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)

The current `docker-compose.yml` persistence mapping is:

```yaml
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)

```bash
# 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

#### 3. Data backup strategy

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

```bash
# 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:

```bash
# 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
```

### Important directory reference

| Directory / File | Purpose | Must persist? |
|-----------|------|---------------|
| PostgreSQL named volume (`postgres_data`) | PostgreSQL data files | Must preserve |
| `.env` | Database password, unified `SECRET_KEY`, and other environment variables | Must preserve and never regenerate |
| `/app/uploads/avatars/` | User avatar files | Must preserve original mount |
| `/app/config.toml` | Application config file | Must preserve original file |
| `/app/logs/` | Application and Supervisor logs | Must preserve original directory; restarts append, old logs are not cleared |

## ⚙️ Environment configuration

### Config file reference

Main config file: `config.toml`

```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"
```

### Environment variables (Docker)

```bash
# .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
```

### CORS configuration

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.

```bash
# 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:`:

```yaml
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

| Variable | Default | Description |
|---|---:|---|
| `DB_POOL_SIZE` | `10` | Per-worker steady-state PostgreSQL business connections |
| `DB_MAX_OVERFLOW` | `20` | Per-worker temporary overflow connections |
| `DB_POOL_TIMEOUT` | `30` | Business pool wait seconds |
| `DB_POOL_RECYCLE` | `1800` | PostgreSQL connection recycle seconds |
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `300` | Login failure counting window |
| `AUTH_RATE_LIMIT_MAX_ATTEMPTS` | `8` | Max login failures per window |
| `AUTH_RATE_LIMIT_LOCKOUT_SECONDS` | `600` | Login lockout seconds |
| `AUTH_RATE_LIMIT_CLEANUP_INTERVAL_SECONDS` | `300` | Expired rate-limit record cleanup interval |
| `REGISTER_RATE_LIMIT_MAX_ATTEMPTS` | `10` | Max registration attempts per IP per window |
| `REGISTER_RATE_LIMIT_WINDOW_SECONDS` | `600` | Registration IP counting window |
| `JWXT_HTTP_TIMEOUT_GET` | `10` | Academic affairs GET request timeout seconds |
| `JWXT_HTTP_TIMEOUT_POST` | `15` | Academic affairs POST request timeout seconds |
| `JWXT_MAX_CONCURRENCY` | `3` | Max concurrency for classroom-availability upstream requests |
| `CLASSROOM_SESSION_TTL_SECONDS` | `600` | Classroom-availability auth session lifetime |
| `CLASSROOM_SESSION_MAX_ITEMS` | `256` | Per-process classroom-availability session capacity |
| `CLASSROOM_SESSION_CLEANUP_INTERVAL_SECONDS` | `60` | Session 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:

```bash
# 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)

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**.

### Injection restrictions (summary)

- 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)

- **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`

### Reverse proxy / CSP considerations

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.

### Default accounts

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.

#### Retrieving the initial admin password

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)

##### Docker / docker compose

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

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

##### Direct run (plain install)

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.

##### systemd (plain install)

If you host the backend with systemd, use:

```bash
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.

## 🔧 Troubleshooting

### Common issues

#### 1. Container fails to start

```bash
# 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

```bash
# 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](https://github.com/CelPlume/SDNUChronoSync/blob/main/scripts/migrations/README_PG.md) 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:

```bash
# 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
```

#### 3. File upload fails

```bash
# Check upload directory permissions
ls -la data/uploads/

# Fix permissions
chmod -R 755 data/uploads/
```

#### 4. Frontend assets fail to load

```bash
# 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

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:

```bash
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.

### Viewing logs

```bash
# 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
```

### Performance optimization

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`:

```ini
# 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:

```bash
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.

## 📞 Support

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!**
