Skip to content

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

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:

  1. Never apply migrations via raw SQL. Always go through migrate.js / Drizzle's migrator. Running DDL by hand bypasses the __drizzle_migrations tracking table and will cause future migrations to fail or produce inconsistent state.

  2. Never use drizzle-kit migrate from 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 via ssh dokku@<host> run <app> node backend/dist/scripts/migrate.js.

  3. Check the journal timestamp before committing a new migration. Drizzle's migrator orders by created_at (the when field in _journal.json). If a new migration's when value is less than the last applied migration's created_at, it will be silently skipped. Always ensure the new entry's when is greater than all previous entries. The timestamp must be a Unix millisecond value greater than the previous migration — use Date.now() at generation time.

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

  5. Never run db:seed on 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

  1. Configure app.json
{
  "expo": {
    "ios": {
      "bundleIdentifier": "com.example.coachingapp",
      "buildNumber": "1.0.0"
    }
  }
}
  1. Build with EAS
cd mobile
eas build --platform ios --profile production
  1. Submit to App Store
    eas submit --platform ios
    

Android - Google Play

  1. Configure app.json
{
  "expo": {
    "android": {
      "package": "com.example.coachingapp",
      "versionCode": 1
    }
  }
}
  1. Build with EAS
eas build --platform android --profile production
  1. Submit to Play Store
    eas submit --platform android
    

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 (staging or production).
  • 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 development builds (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:

curl https://api.example.com/api/health

Expected response:

{ "status": "ok" }

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:
    cd frontend && eas update --channel production --message "revert: rollback to previous" --republish
    
  • 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

High Memory Usage

# Check memory usage
docker stats

# Restart service to clear memory
docker-compose restart backend

Resources