Docker VPS Server Setup Runbook
Doctor's Private Practice SaaS | Next.js, Prisma, PostgreSQL, Socket.IO
| Field | Value |
|---|---|
| Target server | Ubuntu 22.04 or 24.04 LTS VPS |
| Deployment model | Docker Compose with bundled PostgreSQL service |
| Reverse proxy | Caddy terminating HTTPS and proxying to the app on port 3000 |
| Generated | May 28, 2026 |
| Primary repo sources | Dockerfile, docker-compose.yml, .env.example, package.json, docs/data-retention.md |
Use this runbook as the production operator checklist. It assumes the application will run behind HTTPS at https://clinic.example.com, with only ports 80 and 443 exposed publicly.
1. Deployment Architecture
The production stack is a small Docker Compose deployment. Caddy runs on the host or in a separate managed service, terminates TLS, and forwards HTTP/WebSocket traffic to the app container.
| Component | Role | Network | Notes |
|---|---|---|---|
| caddy | Host reverse proxy | Public 80/443 | Issues TLS certificates and reverse proxies to 127.0.0.1:3000. |
| app | Next.js custom server | Container 3000 | Built from Dockerfile; runs tsx server.ts and serves Socket.IO at /api/socket/io. |
| db | PostgreSQL 16 | Container 5432 | Uses postgres:16-alpine, postgres_data volume, and healthcheck via pg_isready. |
| uploads_data | Persistent volume | /app/data/uploads | Stores local uploads when STORAGE_PROVIDER=local. Prefer S3-compatible storage for production. |
| minio | Optional local S3 | 9000/9001 | Compose profile local only. Do not expose directly to the public internet. |
Public Port Policy
| Port | Service | Exposure | Operator rule |
|---|---|---|---|
| 22 | SSH | Restricted | Allow only trusted admin IPs if possible. |
| 80 | HTTP | Public | Used by Caddy for ACME HTTP challenge and redirect to HTTPS. |
| 443 | HTTPS | Public | Primary application entrypoint. |
| 3000 | App | Private | Bind to localhost or block in firewall; Caddy should be the public entrypoint. |
| 5432 | Postgres | Private | Do not expose publicly. Remove host mapping unless needed for admin access. |
| 9000/9001 | MinIO | Private | Only for local profile or private administration. |
2. Server Prerequisites
Prepare these items before the first deployment:
- Ubuntu 22.04 or 24.04 LTS VPS with a non-root sudo user.
- Docker Engine and the Docker Compose v2 plugin installed and verified.
- A DNS A/AAAA record for clinic.example.com pointing at the VPS.
- Caddy installed on the host, or an equivalent reverse proxy with WebSocket support.
- A firewall policy where only SSH, HTTP, and HTTPS are reachable from the internet.
- Production secrets generated with openssl rand -hex 32 or an approved secret manager.
- SMTP credentials for password reset email, or an explicit decision to run in email stub mode.
- S3-compatible storage credentials for production uploads, unless local single-node storage is accepted.
- Optional Daily.co, Twilio, and AI provider credentials for telemedicine, messaging, and AI features.
Verification Commands
docker --version
docker compose version
sudo ufw status verbose
dig +short clinic.example.com3. Environment Configuration
Create .env from .env.example on the server and fill it before starting the app. Never commit the real .env file. The Compose app service reads env_file: .env and also constructs DATABASE_URL from the POSTGRES_* values for container-to-container database access.
cp .env.example .env
openssl rand -hex 32Required Production Values
| Variable | Production value | Purpose |
|---|---|---|
| NEXTAUTH_SECRET | Random secret | Generate a strong unique value; do not reuse between environments. |
| NEXTAUTH_URL | https://clinic.example.com | Must match the public HTTPS origin for secure auth cookies. |
| NEXT_PUBLIC_APP_URL | https://clinic.example.com | Base URL used in prescription, telemedicine, and password-reset links. |
| POSTGRES_PASSWORD | Strong random password | Used by the Compose db service and app DATABASE_URL. |
| POSTGRES_USER | postgres or app-specific user | Keep consistent with docker-compose.yml defaults or your database role. |
| POSTGRES_DB | doc_practice | Database name used by the bundled Postgres service. |
| AI_KEY_ENCRYPTION_SECRET | Random 32+ chars | Encrypts AI and storage credentials at rest. |
| CRON_SECRET | Random 32+ chars | Bearer token for /api/internal/cron/data-retention. |
| RETENTION_IP_HASH_SALT | Random 16+ chars | Required in production for audit-log IP hashing. |
| NEXT_PUBLIC_DEBUG_ERRORS | false | Never enable in production; it exposes raw errors. |
Recommended Integrations
| Variable | Typical value | Purpose |
|---|---|---|
| SMTP_HOST, SMTP_PORT, SMTP_SECURE | SMTP relay | Needed for real password reset email. |
| SMTP_USER, SMTP_PASSWORD, EMAIL_FROM | SMTP credentials | If absent, email is logged in stub mode. |
| STORAGE_PROVIDER | s3 | Recommended for production lab reports and profile images. |
| AWS_REGION | Region near users | Default example is us-east-1; choose a compliant region for your patients. |
| AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY | S3 credentials | Use least-privilege credentials for the upload bucket. |
| S3_BUCKET | Bucket name | Preferred bucket variable; AWS_S3_BUCKET is also read by the app. |
| AWS_ENDPOINT_URL | Blank for AWS | Set only for MinIO or another S3-compatible endpoint. |
Optional Features
| Variable | Typical value | Purpose |
|---|---|---|
| DAILY_API_KEY | Daily.co key | Enables telemedicine room creation. |
| TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN | Twilio credentials | Required for real SMS or WhatsApp sends. |
| TWILIO_FROM_NUMBER | +15551234567 | SMS sender in E.164 format. |
| TWILIO_WHATSAPP_FROM | +15551234567 | WhatsApp-enabled sender; do not include whatsapp: prefix. |
| AI_ENABLED, AI_PROVIDER, AI_MODEL | false / openai / model id | Controls the AI tab and provider fallback. |
| OPENAI_API_KEY or provider key | Provider secret | Used only when AI is enabled and no admin/user key overrides it. |
| RETENTION_*_DAYS, RETENTION_BATCH_SIZE | Defaults from .env.example | Override retention policy windows and batch size. |
| LOCAL_STORAGE_ROOT | /app/data/uploads | Only for single-node local file storage with volume backups. |
4. First Deployment
Run commands from the application directory on the VPS, for example /opt/doc-private-practice.
| Step | Action | Command or detail |
|---|---|---|
| 1 | Place the repository | Clone or copy the repo to /opt/doc-private-practice and check out the release commit. |
| 2 | Create .env | Copy .env.example to .env and fill production values. Keep file permissions limited to operators. |
| 3 | Confirm standalone build | Ensure BUILD_STANDALONE=1 is set for pnpm build in the Docker build stage. |
| 4 | Build app image | docker compose build app |
| 5 | Start database | docker compose up -d db |
| 6 | Run migrations | docker compose run --rm app pnpm prisma migrate deploy |
| 7 | Seed baseline data | docker compose run --rm app pnpm prisma db seed |
| 8 | Start application | docker compose up -d app |
| 9 | Check service state | docker compose ps and docker compose logs -f app |
| 10 | Configure Caddy | Proxy clinic.example.com to 127.0.0.1:3000 and reload Caddy. |
Caddyfile Example
clinic.example.com {
reverse_proxy 127.0.0.1:3000
}Caddy supports WebSockets through reverse_proxy, which is required for the app's Socket.IO queue updates.
Seeded Accounts
| Role | Initial password | Production action | |
|---|---|---|---|
| admin@platform.local | super_admin | password | Change immediately after first login. |
| doctor@practice.local | doctor | password | Default clinic staff account. |
| reception@practice.local | receptionist | password | Default clinic staff account. |
| pharmacy@practice.local | pharmacist | password | Default clinic staff account. |
5. Production Hardening
| Area | Target | Operator action |
|---|---|---|
| TLS | Use HTTPS only | Set NEXTAUTH_URL and NEXT_PUBLIC_APP_URL to https://clinic.example.com. |
| Firewall | Expose only 80/443 publicly | Block direct access to 3000, 5432, 9000, and 9001. |
| Compose ports | Prefer localhost binding | Bind app to 127.0.0.1:3000:3000 and remove or restrict DB host port mapping. |
| Secrets | Use strong random values | Rotate secrets on staff turnover or suspected exposure. |
| Debug | Disable debug errors | NEXT_PUBLIC_DEBUG_ERRORS=false in production. |
| Storage | Prefer S3-compatible backend | Use private bucket policy and approved region; local storage needs volume backups. |
| Database | Protect Postgres | Do not expose publicly; require encrypted backups and controlled admin access. |
| 2FA | Enable for staff | Strongly recommend 2FA for doctors, admins, and anyone accessing health records. |
| Logs | Review regularly | Watch for auth failures, retention reports, upload errors, and messaging provider errors. |
Backups
| Asset | Frequency | Procedure |
|---|---|---|
| Database | Daily | pg_dump from db container; encrypt and store off-server. |
| uploads_data volume | Daily if STORAGE_PROVIDER=local | Back up Docker volume or /app/data/uploads equivalent. |
| S3 bucket | Continuous | Enable versioning or lifecycle policy according to clinic retention policy. |
| .env and Caddyfile | After every change | Store in a secure password manager or encrypted operations vault. |
| Restore drill | Quarterly | Restore database and uploads to a staging server and run smoke tests. |
mkdir -p backups
docker compose exec -T db pg_dump -U postgres doc_practice > backups/doc_practice_YYYY-MM-DD.sql
cat backups/doc_practice_YYYY-MM-DD.sql | docker compose exec -T db psql -U postgres doc_practice6. Updates, Restarts, and Rollback
| Task | Command or note |
|---|---|
| Pull release | git fetch --all --tags && git checkout <release-tag-or-commit> |
| Back up first | Take database and upload backups before migrations. |
| Rebuild app | docker compose build app |
| Apply migrations | docker compose run --rm app pnpm prisma migrate deploy |
| Restart app | docker compose up -d app |
| Watch logs | docker compose logs -f app |
| Rollback app | Check out previous release and rebuild. If schema changed incompatibly, restore DB backup. |
Common Operator Commands
| Need | Command |
|---|---|
| Service status | docker compose ps |
| App logs | docker compose logs -f app |
| Database logs | docker compose logs -f db |
| Restart app | docker compose restart app |
| Open Prisma CLI | docker compose run --rm app pnpm prisma --help |
| Stop stack | docker compose down |
7. Scheduled Jobs
The data-retention job can be run from the Super Admin UI, the CLI, or an external scheduler. Run dry-run mode first and confirm counts before allowing a live purge.
| Method | Entry point | Notes |
|---|---|---|
| Super Admin UI | /admin/crons | Run Data retention with Dry run enabled first; manual runs are audited. |
| CLI | docker compose exec app pnpm retention:run | Respects RETENTION_DRY_RUN from the environment. |
| External scheduler | POST /api/internal/cron/data-retention | Use Authorization: Bearer CRON_SECRET. |
External Scheduler Examples
curl -X POST "https://clinic.example.com/api/internal/cron/data-retention" -H "Authorization: Bearer $CRON_SECRET" -H "Content-Type: application/json" -d '{"dryRun": true}'
15 2 * * * curl -fsS -X POST "https://clinic.example.com/api/internal/cron/data-retention" -H "Authorization: Bearer $CRON_SECRET" >/var/log/doc-practice-retention.log 2>&18. Smoke-Test Checklist
| Area | Check | Pass criteria |
|---|---|---|
| Compose | docker compose ps | db is healthy and app is running. |
| HTTPS | Open https://clinic.example.com | Login page loads over HTTPS with no browser warning. |
| Auth | Sign in as admin@platform.local | First login succeeds; immediately change default password. |
| Clinic dashboard | Sign in as doctor@practice.local | Dashboard, sessions, patients, and records load. |
| Migrations | docker compose run --rm app pnpm prisma migrate deploy | Reports no pending failed migrations. |
| Storage | Upload a lab report or profile image | File persists after app container restart. |
| Socket.IO | Open queue display and advance a session | Display updates without refresh through /api/socket/io. |
| Trigger forgot password | SMTP sends email, or stub log appears if SMTP intentionally unset. | |
| Messaging | Send SMS/WhatsApp in staging | Twilio logs show sent status, or stub log appears if unset. |
| Retention | Run data retention dry run | RetentionRunReport appears in logs with expected counts. |
9. Repo Facts Used
| Source | Deployment-relevant fact |
|---|---|
| Dockerfile | Builds from node:20-alpine, enables pnpm, runs pnpm prisma generate, pnpm build, and starts tsx server.ts. |
| next.config.ts | Sets output: standalone only when BUILD_STANDALONE=1. |
| docker-compose.yml | Defines db, app, optional minio profile, postgres_data, uploads_data, and minio_data volumes. |
| server.ts | Runs a custom Next.js HTTP server and Socket.IO server at /api/socket/io on PORT or 3000. |
| .env.example | Documents auth, SMTP, Daily, Twilio, storage, AI, cron, and retention variables. |
| docs/data-retention.md | Documents CRON_SECRET, RETENTION_IP_HASH_SALT, dry-run behavior, and external scheduler call. |
| docs/saas-tenancy.md | Documents seeded accounts, roles, feature flags, storage providers, and demo seed command. |
| docs/testing-messaging.md | Documents Twilio variables, stub behavior, and messaging smoke-test flows. |