Docs/DevOps
DevOps/server-setup-runbook

Docker VPS Server Setup Runbook

Doctor's Private Practice SaaS | Next.js, Prisma, PostgreSQL, Socket.IO

FieldValue
Target serverUbuntu 22.04 or 24.04 LTS VPS
Deployment modelDocker Compose with bundled PostgreSQL service
Reverse proxyCaddy terminating HTTPS and proxying to the app on port 3000
GeneratedMay 28, 2026
Primary repo sourcesDockerfile, 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.

ComponentRoleNetworkNotes
caddyHost reverse proxyPublic 80/443Issues TLS certificates and reverse proxies to 127.0.0.1:3000.
appNext.js custom serverContainer 3000Built from Dockerfile; runs tsx server.ts and serves Socket.IO at /api/socket/io.
dbPostgreSQL 16Container 5432Uses postgres:16-alpine, postgres_data volume, and healthcheck via pg_isready.
uploads_dataPersistent volume/app/data/uploadsStores local uploads when STORAGE_PROVIDER=local. Prefer S3-compatible storage for production.
minioOptional local S39000/9001Compose profile local only. Do not expose directly to the public internet.

Public Port Policy

PortServiceExposureOperator rule
22SSHRestrictedAllow only trusted admin IPs if possible.
80HTTPPublicUsed by Caddy for ACME HTTP challenge and redirect to HTTPS.
443HTTPSPublicPrimary application entrypoint.
3000AppPrivateBind to localhost or block in firewall; Caddy should be the public entrypoint.
5432PostgresPrivateDo not expose publicly. Remove host mapping unless needed for admin access.
9000/9001MinIOPrivateOnly 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.com

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

Required Production Values

VariableProduction valuePurpose
NEXTAUTH_SECRETRandom secretGenerate a strong unique value; do not reuse between environments.
NEXTAUTH_URLhttps://clinic.example.comMust match the public HTTPS origin for secure auth cookies.
NEXT_PUBLIC_APP_URLhttps://clinic.example.comBase URL used in prescription, telemedicine, and password-reset links.
POSTGRES_PASSWORDStrong random passwordUsed by the Compose db service and app DATABASE_URL.
POSTGRES_USERpostgres or app-specific userKeep consistent with docker-compose.yml defaults or your database role.
POSTGRES_DBdoc_practiceDatabase name used by the bundled Postgres service.
AI_KEY_ENCRYPTION_SECRETRandom 32+ charsEncrypts AI and storage credentials at rest.
CRON_SECRETRandom 32+ charsBearer token for /api/internal/cron/data-retention.
RETENTION_IP_HASH_SALTRandom 16+ charsRequired in production for audit-log IP hashing.
NEXT_PUBLIC_DEBUG_ERRORSfalseNever enable in production; it exposes raw errors.

Optional Features

VariableTypical valuePurpose
DAILY_API_KEYDaily.co keyEnables telemedicine room creation.
TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKENTwilio credentialsRequired for real SMS or WhatsApp sends.
TWILIO_FROM_NUMBER+15551234567SMS sender in E.164 format.
TWILIO_WHATSAPP_FROM+15551234567WhatsApp-enabled sender; do not include whatsapp: prefix.
AI_ENABLED, AI_PROVIDER, AI_MODELfalse / openai / model idControls the AI tab and provider fallback.
OPENAI_API_KEY or provider keyProvider secretUsed only when AI is enabled and no admin/user key overrides it.
RETENTION_*_DAYS, RETENTION_BATCH_SIZEDefaults from .env.exampleOverride retention policy windows and batch size.
LOCAL_STORAGE_ROOT/app/data/uploadsOnly 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.

StepActionCommand or detail
1Place the repositoryClone or copy the repo to /opt/doc-private-practice and check out the release commit.
2Create .envCopy .env.example to .env and fill production values. Keep file permissions limited to operators.
3Confirm standalone buildEnsure BUILD_STANDALONE=1 is set for pnpm build in the Docker build stage.
4Build app imagedocker compose build app
5Start databasedocker compose up -d db
6Run migrationsdocker compose run --rm app pnpm prisma migrate deploy
7Seed baseline datadocker compose run --rm app pnpm prisma db seed
8Start applicationdocker compose up -d app
9Check service statedocker compose ps and docker compose logs -f app
10Configure CaddyProxy 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

EmailRoleInitial passwordProduction action
admin@platform.localsuper_adminpasswordChange immediately after first login.
doctor@practice.localdoctorpasswordDefault clinic staff account.
reception@practice.localreceptionistpasswordDefault clinic staff account.
pharmacy@practice.localpharmacistpasswordDefault clinic staff account.

5. Production Hardening

AreaTargetOperator action
TLSUse HTTPS onlySet NEXTAUTH_URL and NEXT_PUBLIC_APP_URL to https://clinic.example.com.
FirewallExpose only 80/443 publiclyBlock direct access to 3000, 5432, 9000, and 9001.
Compose portsPrefer localhost bindingBind app to 127.0.0.1:3000:3000 and remove or restrict DB host port mapping.
SecretsUse strong random valuesRotate secrets on staff turnover or suspected exposure.
DebugDisable debug errorsNEXT_PUBLIC_DEBUG_ERRORS=false in production.
StoragePrefer S3-compatible backendUse private bucket policy and approved region; local storage needs volume backups.
DatabaseProtect PostgresDo not expose publicly; require encrypted backups and controlled admin access.
2FAEnable for staffStrongly recommend 2FA for doctors, admins, and anyone accessing health records.
LogsReview regularlyWatch for auth failures, retention reports, upload errors, and messaging provider errors.

Backups

AssetFrequencyProcedure
DatabaseDailypg_dump from db container; encrypt and store off-server.
uploads_data volumeDaily if STORAGE_PROVIDER=localBack up Docker volume or /app/data/uploads equivalent.
S3 bucketContinuousEnable versioning or lifecycle policy according to clinic retention policy.
.env and CaddyfileAfter every changeStore in a secure password manager or encrypted operations vault.
Restore drillQuarterlyRestore 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_practice

6. Updates, Restarts, and Rollback

TaskCommand or note
Pull releasegit fetch --all --tags && git checkout <release-tag-or-commit>
Back up firstTake database and upload backups before migrations.
Rebuild appdocker compose build app
Apply migrationsdocker compose run --rm app pnpm prisma migrate deploy
Restart appdocker compose up -d app
Watch logsdocker compose logs -f app
Rollback appCheck out previous release and rebuild. If schema changed incompatibly, restore DB backup.

Common Operator Commands

NeedCommand
Service statusdocker compose ps
App logsdocker compose logs -f app
Database logsdocker compose logs -f db
Restart appdocker compose restart app
Open Prisma CLIdocker compose run --rm app pnpm prisma --help
Stop stackdocker 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.

MethodEntry pointNotes
Super Admin UI/admin/cronsRun Data retention with Dry run enabled first; manual runs are audited.
CLIdocker compose exec app pnpm retention:runRespects RETENTION_DRY_RUN from the environment.
External schedulerPOST /api/internal/cron/data-retentionUse 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>&1

8. Smoke-Test Checklist

AreaCheckPass criteria
Composedocker compose psdb is healthy and app is running.
HTTPSOpen https://clinic.example.comLogin page loads over HTTPS with no browser warning.
AuthSign in as admin@platform.localFirst login succeeds; immediately change default password.
Clinic dashboardSign in as doctor@practice.localDashboard, sessions, patients, and records load.
Migrationsdocker compose run --rm app pnpm prisma migrate deployReports no pending failed migrations.
StorageUpload a lab report or profile imageFile persists after app container restart.
Socket.IOOpen queue display and advance a sessionDisplay updates without refresh through /api/socket/io.
EmailTrigger forgot passwordSMTP sends email, or stub log appears if SMTP intentionally unset.
MessagingSend SMS/WhatsApp in stagingTwilio logs show sent status, or stub log appears if unset.
RetentionRun data retention dry runRetentionRunReport appears in logs with expected counts.

9. Repo Facts Used

SourceDeployment-relevant fact
DockerfileBuilds from node:20-alpine, enables pnpm, runs pnpm prisma generate, pnpm build, and starts tsx server.ts.
next.config.tsSets output: standalone only when BUILD_STANDALONE=1.
docker-compose.ymlDefines db, app, optional minio profile, postgres_data, uploads_data, and minio_data volumes.
server.tsRuns a custom Next.js HTTP server and Socket.IO server at /api/socket/io on PORT or 3000.
.env.exampleDocuments auth, SMTP, Daily, Twilio, storage, AI, cron, and retention variables.
docs/data-retention.mdDocuments CRON_SECRET, RETENTION_IP_HASH_SALT, dry-run behavior, and external scheduler call.
docs/saas-tenancy.mdDocuments seeded accounts, roles, feature flags, storage providers, and demo seed command.
docs/testing-messaging.mdDocuments Twilio variables, stub behavior, and messaging smoke-test flows.