Deployment Guide¶
This guide covers deploying the Coaching App to production environments.
Related setup
If you are exposing local services through Tailscale in development/ops workflows, see Tailscale Services Setup.
Table of Contents¶
- Overview
- Production Docker Configuration
- Pre-Deployment Checklist
- CI/CD Pipeline
- Deploying Backend
- Deploying Frontend
- Deploying Mobile App
- Environment Configuration
- Monitoring & Logging
- Rollback Procedures
Overview¶
Deployment Strategy¶
graph TD
A[Code Push] --> B[CI/CD Pipeline]
B --> C[Run Tests]
C --> D[Build Images]
D --> E[Security Scan]
E --> F{All Checks Pass?}
F -->|No| G[Fail Build]
F -->|Yes| H[Deploy to Staging]
H --> I[Integration Tests]
I --> J{Tests Pass?}
J -->|No| K[Rollback]
J -->|Yes| L[Deploy to Production]
L --> M[Health Checks]
M --> N{Healthy?}
N -->|No| O[Auto Rollback]
N -->|Yes| P[Deployment Complete]
Components¶
| Component | Deployment Method | Platform |
|---|---|---|
| Hono Backend | Docker container | Cloud VPS / Kubernetes |
| PostgreSQL | Managed service or Docker | AWS RDS / Azure Database |
| Redis | Managed service or Docker | AWS ElastiCache / Azure Cache |
| MinIO | Docker or S3-compatible | AWS S3 / Azure Blob |
| Frontend (Expo Web) | Static hosting or Docker | Vercel / Netlify / Docker |
| Mobile App | App stores | Apple App Store / Google Play |
Production Docker Configuration¶
docker-compose.prod.yml¶
version: '3.8'
services:
postgres:
image: postgres:16-alpine
restart: always
environment:
POSTGRES_DB: ${DB_DATABASE}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- backend
# No port exposure - internal only
redis:
image: redis:7-alpine
restart: always
command: redis-server --requirepass ${REDIS_PASSWORD}
networks:
- backend
volumes:
- redis_data:/data
minio:
image: minio/minio:latest
restart: always
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER_FILE: /run/secrets/minio_user
MINIO_ROOT_PASSWORD_FILE: /run/secrets/minio_password
secrets:
- minio_user
- minio_password
volumes:
- minio_data:/data
networks:
- backend
backend:
build:
context: ./backend
dockerfile: Dockerfile
restart: always
ports:
- '3001:3001'
environment:
DATABASE_URL: postgres://${DB_USER}:$(cat /run/secrets/db_password)@postgres:5432/${DB_DATABASE}
REDIS_URL: redis://redis:6379
BETTER_AUTH_SECRET_FILE: /run/secrets/better_auth_secret
BETTER_AUTH_URL: ${PUBLIC_URL}
NODE_ENV: production
PORT: '3001'
SMTP_HOST: ${EMAIL_SMTP_HOST}
SMTP_PORT: ${EMAIL_SMTP_PORT}
SMTP_USER: ${EMAIL_SMTP_USER}
EMAIL_FROM: ${EMAIL_FROM}
secrets:
- db_password
- better_auth_secret
- smtp_password
depends_on:
- postgres
- redis
- minio
networks:
- frontend
- backend
healthcheck:
test:
['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:3001/api/health']
interval: 30s
timeout: 10s
retries: 3
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.prod
restart: always
ports:
- '3000:8080'
environment:
API_URL: ${PUBLIC_URL}
networks:
- frontend
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:8080/health']
interval: 30s
timeout: 10s
retries: 3
secrets:
db_password:
external: true
better_auth_secret:
external: true
minio_user:
external: true
minio_password:
external: true
smtp_password:
external: true
volumes:
postgres_data:
driver: local
redis_data:
driver: local
minio_data:
driver: local
networks:
frontend:
backend:
internal: true
Production Dockerfiles¶
Backend: backend/Dockerfile
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
# Production image
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json .
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3001
CMD ["node", "dist/index.js"]
Frontend: frontend/Dockerfile.prod
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
# Copy source
COPY . .
# Build for web
RUN pnpm build:web
# Production image
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]
Pre-Deployment Checklist¶
Security¶
- All default credentials changed
- Strong random secrets generated
- SSL/TLS certificates configured
- CORS properly configured
- Rate limiting enabled
- Security headers configured
- Secrets stored securely (not in env files)
Testing¶
- All tests passing (
yarn test) - Code coverage ≥ 80%
- Linting passes (
yarn lint) - Type checking passes (
yarn type-check) - Integration tests on staging
- Performance testing completed
Infrastructure¶
- Database backups configured
- Monitoring set up
- Log aggregation configured
- Alerting rules defined
- Health check endpoints working
- Load balancer configured (if applicable)
Documentation¶
- Deployment runbook updated
- Environment variables documented
- Rollback procedures tested
- Incident response plan reviewed
CI/CD Pipeline¶
GitHub Actions Example¶
.github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Run linters
run: yarn lint
- name: Run type check
run: yarn type-check
- name: Run tests
run: yarn test:coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run Snyk security scan
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
build:
needs: [test, security-scan]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v3
- name: Log in to registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Backend image
uses: docker/build-push-action@v4
with:
context: ./backend
file: ./backend/Dockerfile.prod
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/backend:${{ github.sha }}
- name: Build and push Frontend image
uses: docker/build-push-action@v4
with:
context: ./frontend
file: ./frontend/Dockerfile.prod
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/frontend:${{ github.sha }}
deploy-staging:
needs: build
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Deploy to staging
run: |
# SSH into staging server and pull images
# docker-compose pull && docker-compose up -d
deploy-production:
needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
steps:
- name: Deploy to production
run: |
# SSH into production server and pull images
# Perform rolling update
Deploying Backend (Hono)¶
Option 1: Docker Compose¶
# Create secrets
echo "secure-db-password" | docker secret create db_password -
echo "$(openssl rand -base64 32)" | docker secret create better_auth_secret -
# Deploy
docker stack deploy -c docker-compose.yml -c docker-compose.prod.yml coaching-app
# Check status
docker stack ps coaching-app
# View logs
docker service logs coaching-app_backend
Option 2: Kubernetes¶
backend-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
spec:
replicas: 3
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
containers:
- name: backend
image: ghcr.io/your-org/coaching-app/backend:latest
ports:
- containerPort: 3001
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: backend-secrets
key: database-url
- name: BETTER_AUTH_SECRET
valueFrom:
secretKeyRef:
name: backend-secrets
key: better-auth-secret
livenessProbe:
httpGet:
path: /api/health
port: 3001
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /api/health
port: 3001
initialDelaySeconds: 5
periodSeconds: 5
Database Migrations¶
Migrations run automatically when the backend container starts (the migrate.js script runs before the server process). For Dokku staging/production you can also trigger them on-demand:
# Run pending migrations on Dokku staging
ssh dokku@fenrir run praxia-coaching-app-backend-staging node backend/dist/scripts/migrate.js
# Run pending migrations on Dokku production
ssh dokku@fenrir run praxia-coaching-app-backend-production node backend/dist/scripts/migrate.js
Rules — read before every migration:
-
Never apply migrations via raw SQL. Always go through
migrate.js/ Drizzle's migrator. Running DDL by hand bypasses the__drizzle_migrationstracking table and will cause future migrations to fail or produce inconsistent state. -
Never use
drizzle-kit migratefrom your local machine against a remote database. The Dokku-internal hostname (e.g.dokku-postgres-…) is only reachable from inside the container network. The only supported way to migrate a remote environment is viassh dokku@<host> run <app> node backend/dist/scripts/migrate.js. -
Check the journal timestamp before committing a new migration. Drizzle's migrator orders by
created_at(thewhenfield in_journal.json). If a new migration'swhenvalue is less than the last applied migration'screated_at, it will be silently skipped. Always ensure the new entry'swhenis greater than all previous entries. The timestamp must be a Unix millisecond value greater than the previous migration — useDate.now()at generation time. -
Deploy first, migrate second. The migration file must exist in the running container before you run
migrate.js. Push the new code, wait for the build to succeed, then run migrations. If the container auto-runs migrations on start you can just verify the startup logs. -
Never run
db:seedon staging or production without explicit confirmation from the user. Seed scripts are destructive (they insert records that may conflict with real data) and are for local development only. Ask before running any seed in a shared environment.
Verifying migration state¶
# Check which migrations have been applied on staging
ssh dokku@fenrir postgres:connect praxia-coaching-app-backend-staging-db <<'EOF'
SELECT hash, created_at FROM drizzle.__drizzle_migrations ORDER BY created_at;
EOF
Compare the count against backend/drizzle/migrations/meta/_journal.json — the number of rows must equal the number of entries in the journal.
Adding a new migration¶
# 1. Edit the schema in backend/src/db/schema/
# 2. Generate the migration file
cd backend && pnpm db:generate
# 3. BEFORE COMMITTING — verify the journal timestamp is correct
cat backend/drizzle/migrations/meta/_journal.json | \
python3 -c "import json,sys; j=json.load(sys.stdin); [print(e['idx'], e['tag'], e['when']) for e in j['entries']]"
# The last entry's 'when' must be greater than all previous entries.
# If it is not (e.g. the clock was wrong), edit _journal.json manually before committing.
# 4. Commit both the .sql file and the updated _journal.json
# 5. Deploy the backend — migrations run automatically on container start
Deploying Frontend¶
Option 1: Static Hosting (Vercel/Netlify)¶
# Build
cd frontend
yarn build:web
# Deploy to Vercel
vercel deploy --prod
# Or Netlify
netlify deploy --prod --dir=dist
vercel.json
{
"builds": [
{
"src": "package.json",
"use": "@vercel/static-build",
"config": {
"distDir": "dist"
}
}
],
"env": {
"EXPO_PUBLIC_API_URL": "https://api.example.com"
}
}
Option 2: Docker¶
Already included in docker-compose.prod.yml above.
Deploying Mobile App¶
iOS - App Store¶
- Configure app.json
- Build with EAS
- Submit to App Store
Android - Google Play¶
- Configure app.json
- Build with EAS
- Submit to Play Store
EAS Configuration¶
eas.json
{
"build": {
"preview": {
"channel": "staging",
"distribution": "internal"
},
"production": {
"channel": "production",
"distribution": "store"
}
}
}
Over-the-Air (OTA) Updates¶
The app uses EAS Update (expo-updates) to ship JS/asset changes instantly without a new App Store submission.
How it works¶
- Each build profile is linked to an update channel (
stagingorproduction). - On cold launch, the app checks EAS for a newer bundle on its channel and downloads it in the background.
- The update is applied on the next cold start.
- OTA is disabled in
developmentbuilds (simulator/dev-client).
Update channels¶
| Build profile | Channel |
|---|---|
preview |
staging |
testflight-development |
staging |
production |
production |
Pushing an OTA update¶
Prerequisites: env files required
eas update bundles JS on your local machine, so EAS server-side env vars are not available.
The update scripts use dotenv-cli to inject the correct API_URL and APP_ENV before bundling.
You must have the env files present locally (they are gitignored):
```bash
# Create these files once (they are gitignored — never commit them)
cp frontend/.env.example frontend/.env.staging # then fill in staging values
cp frontend/.env.example frontend/.env.production # then fill in production values
```
See `frontend/.env.example` for the required variables.
# Staging (preview / testflight-dev builds)
pnpm eas:update:staging --message "fix: login screen layout"
# Production
pnpm eas:update:production --message "feat: add session booking"
Or using the generic command with explicit flags:
cd frontend && eas update --channel staging --message "..."
cd frontend && eas update --channel production --message "..."
When OTA is NOT sufficient (new binary required)¶
| Change | Requires new build? |
|---|---|
| JS/TS logic, screens, styles | No — OTA works |
| New native module | Yes |
| Expo SDK version bump | Yes |
runtimeVersion change |
Yes |
app.config.js native fields (permissions, plugins) |
Yes |
Environment Configuration¶
Production Environment Variables¶
backend/.env
# Public URL of the API
BETTER_AUTH_URL=https://api.example.com
PUBLIC_URL=https://api.example.com
# Database (use managed service in production)
DATABASE_URL=postgres://coaching_app:SECRET@postgres.example.com:5432/coaching_prod
# Redis
REDIS_URL=redis://redis:6379
# Better-auth
BETTER_AUTH_SECRET=<32-char-random-string-from-secrets>
NODE_ENV=production
PORT=3001
# Email (SMTP)
EMAIL_FROM=noreply@example.com
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASS=<sendgrid-api-key-from-secrets>
Secret Rotation
Rotate BETTER_AUTH_SECRET to immediately invalidate all active sessions. Rotate DATABASE_URL password after any suspected breach.
Monitoring & Logging¶
Health Checks¶
Backend health endpoint:
Expected response:
Logging¶
Centralized logging with ELK stack:
# docker-compose.prod.yml additions
services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
logstash:
image: docker.elastic.co/logstash/logstash:8.11.0
kibana:
image: docker.elastic.co/kibana/kibana:8.11.0
ports:
- '5601:5601'
Metrics¶
Add Prometheus monitoring:
services:
prometheus:
image: prom/prometheus:latest
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
ports:
- '9090:9090'
grafana:
image: grafana/grafana:latest
ports:
- '3000:3000'
Rollback Procedures¶
Rolling Back Docker Deployment¶
# Tag current version as backup
docker tag coaching-app_backend:latest coaching-app_backend:backup
# Pull previous version
docker pull ghcr.io/your-org/coaching-app/backend:previous-sha
# Re-tag as latest
docker tag ghcr.io/your-org/coaching-app/backend:previous-sha coaching-app_backend:latest
# Restart services
docker-compose up -d
# Verify
curl https://api.example.com/api/health
Rolling Back Database Migration¶
# Drizzle doesn't have built-in down migrations — restore from backup
# or manually apply a reverse migration SQL file
# Restore from backup
pg_restore -U coaching_app -d coaching_app backup.dump
Rolling Back Mobile App¶
- OTA rollback (fastest): In EAS dashboard → Updates → select the previous update → Republish, or:
- iOS: Submit previous version to App Store
- Android: Promote previous release in Play Console
- Emergency: Use staged rollout to limit impact
Troubleshooting Production Issues¶
Service Won't Start¶
# Check logs
docker-compose logs backend
# Check health
docker-compose ps
# Restart service
docker-compose restart backend
Database Connection Issues¶
# Test connection
docker-compose exec postgres pg_isready -U coaching_app
# Check backend env
docker-compose exec backend env | grep DATABASE_URL