feat(deploy): add blue-green deployment with health endpoint

- Add /api/health endpoint that checks database connectivity
- Set up blue-green deployment with two containers (abaci-blue, abaci-green)
- Add docker-compose.yaml with YAML anchors for DRY config
- Add generate-compose.sh to create blue/green compose files from main
- Update deploy.sh with NAS-specific fixes (scp -O, PATH for docker)
- Fix deploy.sh to not overwrite production .env by default

The blue-green setup allows zero-downtime deployments via compose-updater,
which watches separate compose files and restarts containers independently.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2026-01-14 17:04:01 -06:00
parent b08a796058
commit b47992f770
8 changed files with 678 additions and 412 deletions

View File

@ -2,166 +2,218 @@
## Overview
The Soroban Abacus Flashcards application is deployed to production at `https://abaci.one` using a fully automated CI/CD pipeline. The system follows the established NAS deployment pattern with Docker containerization, GitHub Actions for CI/CD, Traefik reverse proxy, Watchtower for auto-updates, and Porkbun DDNS integration.
The Soroban Abacus Flashcards application is deployed to production at `https://abaci.one` using a blue-green deployment strategy with zero-downtime updates.
## Architecture
```
User Request → Cloudflare → abaci.one (DDNS) → Synology NAS → Traefik → Docker Container
User Request → Cloudflare → abaci.one (DDNS) → Synology NAS → Traefik → Docker (Blue + Green)
```
### Components
1. **Source**: Monorepo with pnpm workspaces and Turborepo
2. **CI/CD**: GitHub Actions with automated Docker builds
2. **CI/CD**: GitHub Actions builds and pushes Docker images
3. **Registry**: GitHub Container Registry (ghcr.io)
4. **Deployment**: Synology NAS with Docker Compose
5. **Reverse Proxy**: Traefik with Let's Encrypt SSL
6. **Auto-Updates**: Watchtower (5-minute polling)
4. **Auto-Deploy**: compose-updater detects new images
5. **Load Balancing**: Traefik routes to healthy containers
6. **Reverse Proxy**: Traefik with Let's Encrypt SSL
7. **DNS**: Porkbun DDNS for dynamic IP updates
## Blue-Green Deployment
Two containers (`abaci-blue` and `abaci-green`) run simultaneously:
- **Shared resources**: Both containers mount the same data volumes
- **Health checks**: Traefik only routes to healthy containers
- **Zero downtime**: When one container restarts, the other serves traffic
- **Automatic updates**: compose-updater pulls new images and restarts containers
### How It Works
```
1. Push to main branch
2. GitHub Actions builds and pushes Docker image to ghcr.io
3. compose-updater detects new image (checks every 5 minutes)
4. compose-updater restarts containers one at a time
5. Traefik health checks ensure traffic only goes to ready containers
```
### Health Check Endpoint
The `/api/health` endpoint verifies container readiness:
```bash
curl https://abaci.one/api/health
```
Response:
```json
{
"status": "healthy",
"timestamp": "2025-01-14T12:00:00.000Z",
"checks": {
"database": {
"status": "ok",
"latencyMs": 2
}
}
}
```
- Returns `200 OK` when healthy
- Returns `503 Service Unavailable` when unhealthy
- Traefik uses this to determine if a container should receive traffic
## Deployment Process
### 1. Code Push Triggers Build
### Automatic Deployment
When code is pushed to the `main` branch:
1. GitHub Actions workflow (`.github/workflows/deploy.yml`) triggers
2. Multi-stage Docker build runs:
- Install dependencies with pnpm
- Generate Panda CSS styled-system
- Build Next.js app with Turborepo
- Create optimized production image
3. Image pushed to `ghcr.io/antialias/soroban-abacus-flashcards`
1. **Build Phase** (GitHub Actions):
- Multi-stage Docker build
- Image pushed to `ghcr.io/antialias/soroban-abacus-flashcards`
### 2. Automatic Deployment
2. **Deploy Phase** (compose-updater on NAS):
- Detects new image within 5 minutes
- Pulls new image
- Restarts containers (one at a time)
- Traefik routes traffic to healthy containers
1. **Global Watchtower** (located at `/volume1/homes/antialias/projects/global-services/`) polls GitHub Container Registry every 5 minutes
2. Detects new image version and pulls it
3. Gracefully stops old container and starts new one
4. Traefik automatically routes traffic to new container
### Manual Deployment
**Note**: We use a centralized global Watchtower service that monitors ALL containers across the NAS, rather than project-specific Watchtower instances.
```bash
# From local machine (with SSH access to NAS)
./nas-deployment/deploy.sh
```
### 3. DNS and SSL
1. Porkbun DDNS keeps `abaci.one` pointing to current NAS IP
2. Traefik handles SSL certificate provisioning via Let's Encrypt
3. Automatic HTTPS redirect and certificate renewal
This script handles migration from the old single-container setup to blue-green.
## File Structure
```
/
├── Dockerfile # Multi-stage build configuration
├── .github/workflows/deploy.yml # CI/CD pipeline
├── apps/web/next.config.js # Next.js standalone output config
├── .github/workflows/deploy.yml # CI/CD pipeline (build + push)
├── apps/web/
│ └── src/app/api/health/route.ts # Health check endpoint
├── nas-deployment/
│ ├── docker-compose.yaml # Production container orchestration
│ ├── docker-compose.yaml # Blue-green container config
│ ├── deploy.sh # Manual deployment/migration script
│ └── .env # Environment variables (not committed)
└── DEPLOYMENT.md # This documentation
```
## Key Configuration Files
## Docker Compose Configuration
### Dockerfile
Both containers share the same volumes and Traefik service:
- Multi-stage build optimized for monorepo
- pnpm workspace dependency management
- Panda CSS generation step
- Next.js standalone output for optimal Docker deployment
- Proper static file serving configuration
```yaml
services:
blue:
image: ghcr.io/antialias/soroban-abacus-flashcards:latest
container_name: abaci-blue
volumes:
- ./data:/app/apps/web/data # Shared database
- ./uploads:/app/uploads # Shared uploads
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
labels:
- "traefik.http.services.abaci.loadbalancer.server.port=3000"
- "traefik.http.services.abaci.loadbalancer.healthcheck.path=/api/health"
### GitHub Actions Workflow
- Triggers on push to main branch
- Builds and pushes Docker images to ghcr.io
- Uses GitHub Container Registry for hosting
- Simplified build process (no type checking in CI)
### Docker Compose
- Single service deployment
- Traefik labels for reverse proxy routing
- Watchtower compatibility for auto-updates
- Environment variable configuration
### Next.js Configuration
- Standalone output mode for Docker optimization
- Build optimization settings
- Static file serving configuration
## Environment Variables
Required in `nas-deployment/.env`:
```bash
# GitHub Container Registry
GITHUB_TOKEN=<personal_access_token>
GITHUB_USERNAME=<username>
# Application
NODE_ENV=production
green:
# Same configuration as blue, shares volumes
```
## NAS Deployment Directory
```
/volume1/homes/antialias/projects/abaci.one/
├── docker-compose.yaml
├── .env
└── logs/
```
Traefik automatically load balances between both containers, routing only to healthy ones.
## Monitoring and Maintenance
### Checking Deployment Status
### Check Deployment Status
```bash
# On NAS
docker-compose ps
docker-compose logs -f app
# Check running containers
ssh nas.home.network "docker ps | grep abaci"
# GitHub Actions status
gh run list --repo antialias/soroban-abacus-flashcards
# Check health of both containers
curl https://abaci.one/api/health
# Check compose-updater logs
ssh nas.home.network "docker logs --tail 50 compose-updater"
```
### Manual Updates
### View Logs
```bash
# Force update (if needed)
docker-compose pull
docker-compose up -d
# Blue container logs
ssh nas.home.network "docker logs -f abaci-blue"
# Green container logs
ssh nas.home.network "docker logs -f abaci-green"
```
### DNS Status
### Force Immediate Update
```bash
# Check DNS resolution
nslookup abaci.one
# Restart compose-updater to trigger immediate check
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-compose -f docker-compose.updater.yaml restart"
# Or manually pull and restart
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-compose pull && docker-compose up -d"
```
## Troubleshooting
### Common Issues
### Health Check Failing
1. **CSS not loading**: Check static file paths in Dockerfile
2. **DNS not updating**: Verify DDNS configuration in existing updater
3. **Container not starting**: Check environment variables and logs
4. **SSL certificate issues**: Traefik will auto-renew, check Traefik logs
1. Check container logs:
```bash
ssh nas.home.network "docker logs abaci-blue"
```
### Build Failures
2. Test health endpoint manually:
```bash
ssh nas.home.network "docker exec abaci-blue curl -sf http://localhost:3000/api/health"
```
1. **TypeScript errors**: Review apps/web/src files for type issues
2. **Dependency issues**: Verify workspace dependencies are correctly referenced
3. **Docker build timeout**: Check .dockerignore and build optimization
3. Check database connectivity
### Production Issues
### Container Not Updating
1. **Site not accessible**: Check Traefik configuration and DNS
2. **Auto-updates not working**: Verify Watchtower is running
3. **Performance issues**: Monitor container resources
1. Verify GitHub Actions completed successfully
2. Check compose-updater is running:
```bash
ssh nas.home.network "docker ps | grep compose-updater"
```
3. Check compose-updater logs for errors
### Both Containers Unhealthy
```bash
# Force restart both
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-compose restart"
```
## Migration from Single Container
If upgrading from the old single-container setup:
```bash
./nas-deployment/deploy.sh
```
This script will:
1. Stop the old `soroban-abacus-flashcards` container
2. Stop compose-updater temporarily
3. Deploy the new docker-compose.yaml
4. Start both blue and green containers
5. Restart compose-updater
## Security
@ -169,61 +221,15 @@ nslookup abaci.one
- Minimal Alpine Linux base image
- GitHub Container Registry with token authentication
- Traefik handles SSL termination
- No sensitive data in committed files
- Health check doesn't expose sensitive data
## Dependencies
## Performance
### External Services
- GitHub (source code and CI/CD)
- GitHub Container Registry (image hosting)
- Porkbun (DNS management)
- Let's Encrypt (SSL certificates)
### Infrastructure
- Synology NAS (hosting)
- Docker and Docker Compose
- Traefik reverse proxy
- **Global Watchtower** (centralized auto-updater for all containers)
## Backup and Recovery
### Container Recovery
```bash
# Stop and remove container
docker-compose down
# Pull latest image
docker-compose pull
# Start fresh container
docker-compose up -d
```
### Configuration Backup
- `docker-compose.yaml` and `.env` files are backed up via NAS snapshots
- Source code is version controlled in GitHub
- Container images stored in GitHub Container Registry
## Performance Optimization
- Next.js standalone output reduces image size
- Multi-stage Docker build minimizes production image
- Panda CSS pre-compilation
- Traefik connection pooling and compression
- Docker layer caching in CI/CD
## Future Improvements
- Health checks in Docker configuration
- Container resource limits
- Monitoring and alerting integration
- Staging environment setup
- Database integration (if needed)
- Zero-downtime deployments via load balancing
- Health checks prevent routing to unhealthy containers
- Both containers share data - no sync needed
- SQLite WAL mode handles concurrent access
---
_This deployment system provides a production-ready, automated, and maintainable infrastructure for the Soroban Abacus Flashcards application._
_Last updated: January 2025_

View File

@ -12,23 +12,14 @@ This document describes the production deployment infrastructure and procedures
- Server is not accessible from external networks
- **Project Directory**: `/volume1/homes/antialias/projects/abaci.one`
### Docker Configuration
### Deployment Strategy: Blue-Green with Load Balancing
This deployment uses **two separate Docker Compose projects**:
Two containers (`abaci-blue` and `abaci-green`) run simultaneously:
1. **soroban-app** (`docker-compose.yaml`)
- Main web application
- Container: `soroban-abacus-flashcards`
- Image: `ghcr.io/antialias/soroban-abacus-flashcards:main`
- Port: 3000 (internal to Docker network)
2. **soroban-updater** (`docker-compose.updater.yaml`)
- Automatic update service
- Container: `compose-updater`
- Image: `virtualzone/compose-updater:latest`
- Checks for new images every 5 minutes
**Why separate projects?** If compose-updater was in the same project as the app, running `docker-compose down` would kill itself mid-update. Separate projects prevent this.
- **Shared resources**: Both containers mount the same data volumes
- **Health checks**: Traefik only routes to healthy containers
- **Zero downtime**: When one container restarts, the other serves traffic
- **Automatic updates**: compose-updater pulls new images and restarts containers
### Auto-Deployment with compose-updater
@ -36,20 +27,32 @@ This deployment uses **two separate Docker Compose projects**:
- **Update frequency**: Every **5 minutes** (configurable via `INTERVAL=5`)
- Works WITH docker-compose files (respects configuration, volumes, environment variables)
- Automatically cleans up old images (`CLEANUP=1`)
- No manual intervention required for deployments after pushing to main
**Key advantages over Watchtower:**
## Health Check Endpoint
- Respects docker-compose.yaml configuration
- Re-reads `.env` file on every update
- Can manage multiple docker-compose projects
- Container labels control which containers to watch:
```yaml
labels:
- "docker-compose-watcher.watch=1"
- "docker-compose-watcher.dir=/volume1/homes/antialias/projects/abaci.one"
- "com.centurylinklabs.watchtower.enable=false" # Disables Watchtower for this container
```
The `/api/health` endpoint verifies container readiness:
```bash
curl https://abaci.one/api/health
```
Response:
```json
{
"status": "healthy",
"timestamp": "2025-01-14T12:00:00.000Z",
"checks": {
"database": {
"status": "ok",
"latencyMs": 2
}
}
}
```
- Returns `200 OK` when healthy
- Returns `503 Service Unavailable` when unhealthy
- Traefik uses this to determine if a container should receive traffic
## Database Management
@ -67,7 +70,7 @@ This deployment uses **two separate Docker Compose projects**:
2. Logs: `🔄 Running database migrations...`
3. Drizzle migrator runs all pending migrations
4. Logs: `✅ Migrations complete` (on success)
5. Logs: `❌ Migration failed: [error]` (on failure, process exits)
5. Health check passes only after migrations complete
### Nuke and Rebuild Database
@ -80,17 +83,17 @@ ssh nas.home.network
# Navigate to project directory
cd /volume1/homes/antialias/projects/abaci.one
# Stop the container
/usr/local/bin/docker-compose down
# Stop both containers
docker stop abaci-blue abaci-green
# Remove database files
rm -f data/sqlite.db data/sqlite.db-shm data/sqlite.db-wal
# Restart container (migrations will rebuild DB)
/usr/local/bin/docker-compose up -d
# Restart containers (migrations will rebuild DB)
docker start abaci-blue abaci-green
# Check logs to verify migration success
/usr/local/bin/docker logs soroban-abacus-flashcards | grep -E '(Migration|Starting)'
docker logs abaci-blue | grep -E '(Migration|Starting)'
```
## CI/CD Pipeline
@ -99,30 +102,21 @@ rm -f data/sqlite.db data/sqlite.db-shm data/sqlite.db-wal
When code is pushed to `main` branch:
1. **Workflows triggered**:
- `Build and Deploy` - Builds Docker image and pushes to GHCR
- `Release` - Manages semantic versioning and releases
- `Verify Examples` - Runs example tests
- `Deploy Storybooks to GitHub Pages` - Publishes Storybook
1. **Build and Push job**:
- Builds Docker image
- Tags as `main` and `latest`
- Pushes to GitHub Container Registry (ghcr.io)
2. **Image build**:
- Built image is tagged as `main` (also `latest` for compatibility)
- Pushed to GitHub Container Registry (ghcr.io)
- Typically completes within 1-2 minutes
3. **Deployment**:
- compose-updater detects new image (within 5 minutes)
2. **Deploy** (compose-updater on NAS):
- Detects new image within 5 minutes
- Pulls new image
- Runs `docker-compose down && docker-compose up -d`
- Cleans up old images
- Total deployment time: ~5-7 minutes from push to production (15-30 seconds downtime during restart)
- Restarts containers
- Traefik routes traffic to healthy containers
## Manual Deployment Procedures
### Force Pull Latest Image
If you need to immediately deploy without waiting for compose-updater's next check cycle:
```bash
# Option 1: Restart compose-updater (triggers immediate check)
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-compose -f docker-compose.updater.yaml restart"
@ -134,64 +128,83 @@ ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-c
### Check Container Status
```bash
# Check both app and compose-updater
ssh nas.home.network "docker ps | grep -E '(soroban|compose)'"
# Check both containers
ssh nas.home.network "docker ps | grep abaci"
# Check just the app
ssh nas.home.network "docker ps | grep soroban-abacus-flashcards"
# Check health
curl https://abaci.one/api/health
```
### View Logs
```bash
# Application logs - recent
ssh nas.home.network "docker logs --tail 100 soroban-abacus-flashcards"
# Blue container logs
ssh nas.home.network "docker logs --tail 100 abaci-blue"
# Application logs - follow in real-time
ssh nas.home.network "docker logs -f soroban-abacus-flashcards"
# Green container logs
ssh nas.home.network "docker logs --tail 100 abaci-green"
# compose-updater logs - see update activity
# Follow in real-time
ssh nas.home.network "docker logs -f abaci-blue"
# compose-updater logs
ssh nas.home.network "docker logs --tail 50 compose-updater"
# compose-updater logs - follow to watch for updates
ssh nas.home.network "docker logs -f compose-updater"
# Search for specific patterns
ssh nas.home.network "docker logs soroban-abacus-flashcards" | grep -i "error"
```
### Restart Container
### Restart Containers
```bash
# Restart just the app (quick, minimal downtime)
# Restart both (they restart one at a time, maintaining availability)
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-compose restart"
# Full restart (down then up, recreates container)
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-compose down && docker-compose up -d"
# Restart compose-updater (triggers immediate update check)
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-compose -f docker-compose.updater.yaml restart"
```
## Checking Deployed Version
Always verify what's actually running in production:
```bash
# Get commit SHA of running container
ssh nas.home.network 'docker inspect soroban-abacus-flashcards --format="{{index .Config.Labels \"org.opencontainers.image.revision\"}}"'
# Get commit SHA of running containers
ssh nas.home.network 'docker inspect abaci-blue --format="{{index .Config.Labels \"org.opencontainers.image.revision\"}}"'
ssh nas.home.network 'docker inspect abaci-green --format="{{index .Config.Labels \"org.opencontainers.image.revision\"}}"'
# Compare with current HEAD
git rev-parse HEAD
# Or check via the deployment info modal in the app UI
```
## Troubleshooting
### Common Issues
### Health Check Failing
#### 1. Migration Failures
1. Check container logs:
```bash
ssh nas.home.network "docker logs abaci-blue"
```
2. Test health endpoint manually:
```bash
ssh nas.home.network "docker exec abaci-blue curl -sf http://localhost:3000/api/health"
```
3. Check database connectivity
### Container Not Updating
1. Verify GitHub Actions completed successfully
2. Check compose-updater is running:
```bash
ssh nas.home.network "docker ps | grep compose-updater"
```
3. Check compose-updater logs for errors:
```bash
ssh nas.home.network "docker logs --tail 50 compose-updater"
```
### Both Containers Unhealthy
```bash
# Force restart both
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-compose restart"
```
### Migration Failures
**Symptom**: Container keeps restarting, logs show migration errors
@ -201,90 +214,10 @@ git rev-parse HEAD
2. Verify `drizzle/meta/_journal.json` is up to date
3. If migrations are corrupted, may need to nuke database (see above)
#### 2. Container Not Updating
**Symptom**: Changes pushed but production still shows old code
**Possible causes**:
- GitHub Actions build failed - check workflow status with `gh run list`
- compose-updater not running - check with `docker ps | grep compose-updater`
- compose-updater labels incorrect - check container labels
- Image not pulled - manually pull with `docker-compose pull`
- **compose-updater detection issue** - May not detect updates reliably (investigation ongoing - 2025-11-13)
**Debugging**:
```bash
# Check compose-updater is running
ssh nas.home.network "docker ps | grep compose-updater"
# Check compose-updater logs for errors and pull activity
ssh nas.home.network "docker logs --tail 50 compose-updater"
# Look for: "Processing service" followed by pull activity
# If it says "No need to restart" WITHOUT pulling, detection may be broken
# Check container labels are correct
ssh nas.home.network "docker inspect soroban-abacus-flashcards" | grep -A3 "docker-compose-watcher"
# Should show:
# "docker-compose-watcher.watch": "1"
# "docker-compose-watcher.dir": "/volume1/homes/antialias/projects/abaci.one"
```
**Known Issue (2025-11-13)**:
compose-updater sometimes fails to detect updates even when new images are available. Logs show:
```
Processing service soroban-abacus-flashcards (requires build: false, watched: true)...
No need to restart services in /volume1/homes/antialias/projects/abaci.one/docker-compose.yaml
```
Without any `docker pull` activity shown, even with `LOG_LEVEL=debug`. This suggests it's determining "no update needed" without actually checking the remote registry. Root cause under investigation.
**Solution**:
```bash
# Option 1: Manual pull and restart (most reliable)
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-compose pull && docker-compose up -d"
# Option 2: Restart compose-updater to force immediate check (may not always work)
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-compose -f docker-compose.updater.yaml restart"
```
#### 3. Missing Database Columns
**Symptom**: Errors like `SqliteError: no such column: "column_name"`
**Cause**: Migration not registered or not run
**Solution**:
1. Verify migration exists in `drizzle/` directory
2. Check migration is registered in `drizzle/meta/_journal.json`
3. If migration is new, restart container to run migrations
4. If migration is malformed, fix it and nuke database
#### 4. API Returns Unexpected Response
**Symptom**: Client shows errors but API appears to work
**Debugging**:
1. Test API directly with curl: `curl -X POST 'https://abaci.one/api/arcade/rooms' -H 'Content-Type: application/json' -d '...'`
2. Check production logs for errors
3. Verify container is running latest image:
```bash
ssh nas.home.network "/usr/local/bin/docker inspect soroban-abacus-flashcards --format '{{.Created}}'"
```
4. Compare with commit timestamp: `git log --format="%ci" -1`
## Environment Variables
Production environment variables are stored in `.env` file on the server and loaded via `env_file:` in docker-compose.yaml.
**Critical advantage**: compose-updater re-reads the `.env` file on every update, so environment variable changes are automatically picked up without manual intervention.
Common variables:
- `AUTH_URL` - Base URL (https://abaci.one)
@ -298,8 +231,8 @@ To update environment variables:
# Edit .env file on NAS
ssh nas.home.network "vi /volume1/homes/antialias/projects/abaci.one/.env"
# Restart compose-updater (will pick up new .env on next cycle)
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-compose -f docker-compose.updater.yaml restart"
# Restart containers to pick up changes
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-compose restart"
```
## Network Configuration
@ -308,6 +241,7 @@ ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-c
- **HTTPS**: Automatic via Traefik with Let's Encrypt
- **Domain**: abaci.one
- **Exposed Port**: 3000 (internal to Docker network)
- **Load Balancing**: Traefik routes to both containers, health checks determine eligibility
## Security Notes
@ -315,3 +249,4 @@ ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-c
- SSH access is restricted to local network only
- Docker container runs with appropriate user permissions
- Secrets are managed via environment variables, not committed to repo
- Health check endpoint doesn't expose sensitive data

View File

@ -0,0 +1,69 @@
/**
* Health check endpoint for deployment orchestration
*
* GET /api/health
*
* Returns 200 OK when the application is ready to serve traffic:
* - Database connection is working
* - All critical services are initialized
*
* Used by:
* - Red-black deployment scripts to verify new containers are ready
* - Docker healthcheck for container orchestration
* - Load balancers to determine if traffic should be routed
*/
import { NextResponse } from 'next/server'
import { sql } from 'drizzle-orm'
import { db } from '@/db'
export const dynamic = 'force-dynamic'
interface HealthCheckResult {
status: 'healthy' | 'unhealthy'
timestamp: string
checks: {
database: {
status: 'ok' | 'error'
latencyMs?: number
error?: string
}
}
version?: string
commit?: string
}
export async function GET(): Promise<NextResponse<HealthCheckResult>> {
const startTime = Date.now()
const result: HealthCheckResult = {
status: 'healthy',
timestamp: new Date().toISOString(),
checks: {
database: { status: 'ok' },
},
}
// Check database connectivity
try {
const dbStart = Date.now()
await db.run(sql`SELECT 1`)
result.checks.database.latencyMs = Date.now() - dbStart
} catch (error) {
result.status = 'unhealthy'
result.checks.database = {
status: 'error',
error: error instanceof Error ? error.message : 'Unknown database error',
}
}
// Add version info from environment (set during Docker build)
if (process.env.GIT_COMMIT) {
result.commit = process.env.GIT_COMMIT
}
if (process.env.npm_package_version) {
result.version = process.env.npm_package_version
}
const statusCode = result.status === 'healthy' ? 200 : 503
return NextResponse.json(result, { status: statusCode })
}

View File

@ -1,46 +1,162 @@
#!/bin/bash
#
# Migration/Manual Deployment Script for Soroban Abacus Flashcards
#
# This script handles:
# 1. Migration from single-container to blue-green setup
# 2. Manual deployments when you don't want to wait for compose-updater
#
# Usage:
# ./deploy.sh # Deploy/migrate to blue-green setup
#
# Soroban Abacus Flashcards - NAS Deployment Script
# This script deploys the monorepo's apps/web to abaci.one
set -euo pipefail
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NAS_HOST="${NAS_HOST:-nas.home.network}"
NAS_PATH="${NAS_PATH:-/volume1/homes/antialias/projects/abaci.one}"
IMAGE="ghcr.io/antialias/soroban-abacus-flashcards"
NAS_HOST="nas.home.network"
NAS_PATH="/volume1/homes/antialias/projects/abaci.one"
LOCAL_DIR="$(dirname "$0")"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo "🚀 Deploying Soroban Abacus Flashcards to NAS..."
log() {
echo -e "${GREEN}[deploy]${NC} $1"
}
# Stop existing services
echo "📦 Stopping existing services..."
ssh "$NAS_HOST" "cd '$NAS_PATH' && docker-compose down || true"
warn() {
echo -e "${YELLOW}[deploy]${NC} $1"
}
# Copy deployment files
echo "📁 Copying deployment files..."
scp "$LOCAL_DIR/docker-compose.yaml" "$NAS_HOST:$NAS_PATH/"
scp "$LOCAL_DIR/.env" "$NAS_HOST:$NAS_PATH/" 2>/dev/null || echo "⚠️ No .env file found locally - using existing on NAS"
error() {
echo -e "${RED}[deploy]${NC} $1" >&2
}
# Ensure DDNS config is in place (only if it doesn't exist)
ssh "$NAS_HOST" "mkdir -p '$NAS_PATH/ddns-data'"
scp "$LOCAL_DIR/ddns-data/ddns-config.json" "$NAS_HOST:$NAS_PATH/ddns-data/" 2>/dev/null || echo " Using existing DDNS config"
# Run a command on the NAS via SSH
# Synology NAS needs /usr/local/bin in PATH for docker commands
nas_exec() {
ssh "$NAS_HOST" "export PATH=/usr/local/bin:\$PATH && cd '$NAS_PATH' && $1"
}
# Create required directories
echo "📂 Creating required directories..."
ssh "$NAS_HOST" "cd '$NAS_PATH' && mkdir -p public data uploads"
# Check if old single-container setup exists
check_needs_migration() {
if nas_exec "docker ps -a --format '{{.Names}}' | grep -q '^soroban-abacus-flashcards$'"; then
echo "yes"
else
echo "no"
fi
}
# Pull latest image and start services
echo "🐳 Starting services..."
ssh "$NAS_HOST" "cd '$NAS_PATH' && docker-compose pull && docker-compose up -d"
# Migrate from single-container to blue-green setup
migrate_to_blue_green() {
log "Migrating from single-container to blue-green setup..."
# Show status
echo "✅ Deployment complete!"
echo ""
echo "🌐 Services:"
echo " - Soroban Flashcards: https://abaci.one"
echo " - DDNS Web UI: http://$(ssh "$NAS_HOST" "hostname -I | awk '{print \$1}'"):8000"
echo ""
echo "📊 Check status:"
echo " ssh $NAS_HOST 'cd $NAS_PATH && docker-compose ps'"
echo ""
echo "📝 View logs:"
echo " ssh $NAS_HOST 'cd $NAS_PATH && docker-compose logs -f soroban-abacus-flashcards'"
# Stop old container
warn "Stopping old container..."
nas_exec "docker stop soroban-abacus-flashcards 2>/dev/null || true"
nas_exec "docker rm soroban-abacus-flashcards 2>/dev/null || true"
log "Migration complete."
}
# Sync deployment files to NAS
sync_files() {
log "Syncing deployment files to NAS..."
# Check if blue/green files exist (need to be generated)
if [[ ! -f "$SCRIPT_DIR/docker-compose.blue.yaml" ]] || [[ ! -f "$SCRIPT_DIR/docker-compose.green.yaml" ]]; then
warn "Blue/green compose files not found. Generating..."
if [[ -x "$SCRIPT_DIR/generate-compose.sh" ]]; then
"$SCRIPT_DIR/generate-compose.sh"
else
error "generate-compose.sh not found or not executable"
error "Run: ./generate-compose.sh (requires yq)"
exit 1
fi
fi
# Copy all compose files (use -O for legacy SCP protocol - required by Synology NAS)
scp -O "$SCRIPT_DIR/docker-compose.yaml" "$NAS_HOST:$NAS_PATH/"
scp -O "$SCRIPT_DIR/docker-compose.blue.yaml" "$NAS_HOST:$NAS_PATH/"
scp -O "$SCRIPT_DIR/docker-compose.green.yaml" "$NAS_HOST:$NAS_PATH/"
# NEVER overwrite production .env automatically - it contains secrets
# Use --sync-env flag to explicitly copy .env (dangerous!)
if [[ "${SYNC_ENV:-}" == "true" ]]; then
if [[ -f "$SCRIPT_DIR/.env" ]]; then
warn "SYNC_ENV=true - copying local .env to NAS (overwrites production secrets!)"
scp -O "$SCRIPT_DIR/.env" "$NAS_HOST:$NAS_PATH/"
fi
else
log "Keeping existing .env on NAS (use SYNC_ENV=true to overwrite)"
fi
# Ensure directories exist
nas_exec "mkdir -p public data uploads ddns-data"
}
# Main deployment logic
main() {
log "=========================================="
log "Deploying abaci.one (Blue-Green)"
log "=========================================="
log "NAS host: $NAS_HOST"
echo ""
# Sync deployment files
sync_files
echo ""
# Check if we need to migrate from old setup
local needs_migration
needs_migration=$(check_needs_migration)
if [[ "$needs_migration" == "yes" ]]; then
warn "Detected old single-container setup"
migrate_to_blue_green
echo ""
fi
# Pull latest image and start containers
log "Pulling latest image..."
nas_exec "docker-compose pull"
echo ""
log "Starting containers..."
nas_exec "docker-compose up -d"
echo ""
# Wait a moment for containers to start
log "Waiting for containers to start..."
sleep 5
# Check status
log "Container status:"
nas_exec "docker ps --format 'table {{.Names}}\t{{.Status}}' | grep -E '(NAMES|abaci)'"
echo ""
# Test health endpoint
log "Testing health endpoint..."
local health_blue health_green
health_blue=$(nas_exec "docker exec abaci-blue curl -sf http://localhost:3000/api/health 2>/dev/null && echo 'OK' || echo 'FAIL'")
health_green=$(nas_exec "docker exec abaci-green curl -sf http://localhost:3000/api/health 2>/dev/null && echo 'OK' || echo 'FAIL'")
log " abaci-blue: $health_blue"
log " abaci-green: $health_green"
echo ""
log "=========================================="
log "Deployment complete!"
log "=========================================="
log "Site: https://abaci.one"
log "Health: https://abaci.one/api/health"
log ""
log "Both containers are running. Traefik will load balance"
log "between them based on health checks."
log "=========================================="
}
# Run main
main "$@"

View File

@ -0,0 +1,45 @@
version: "3.8"
services:
blue:
image: ghcr.io/antialias/soroban-abacus-flashcards:latest
restart: unless-stopped
env_file:
- .env
volumes:
- ./public:/app/public
- ./data:/app/apps/web/data
- ./uploads:/app/uploads
networks:
- webgateway
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', r => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
container_name: abaci-blue
labels:
traefik.enable: "true"
traefik.http.routers.abaci.rule: "Host(`abaci.one`)"
traefik.http.routers.abaci.entrypoints: websecure
traefik.http.routers.abaci.tls: "true"
traefik.http.routers.abaci.tls.certresolver: myresolver
traefik.http.routers.abaci.middlewares: hsts@docker
traefik.http.routers.abaci.service: abaci
traefik.http.routers.abaci-http.rule: "Host(`abaci.one`)"
traefik.http.routers.abaci-http.entrypoints: web
traefik.http.routers.abaci-http.middlewares: redirect-https@docker
traefik.http.services.abaci.loadbalancer.server.port: "3000"
traefik.http.services.abaci.loadbalancer.healthcheck.path: /
traefik.http.services.abaci.loadbalancer.healthcheck.interval: 10s
traefik.http.middlewares.redirect-https.redirectscheme.scheme: https
traefik.http.middlewares.redirect-https.redirectscheme.permanent: "true"
traefik.http.middlewares.hsts.headers.stsSeconds: "63072000"
traefik.http.middlewares.hsts.headers.stsIncludeSubdomains: "true"
traefik.http.middlewares.hsts.headers.stsPreload: "true"
docker-compose-watcher.watch: "1"
docker-compose-watcher.dir: /volume1/homes/antialias/projects/abaci.one
docker-compose-watcher.file: docker-compose.blue.yaml
networks:
webgateway:
external: true

View File

@ -0,0 +1,45 @@
version: "3.8"
services:
green:
image: ghcr.io/antialias/soroban-abacus-flashcards:latest
restart: unless-stopped
env_file:
- .env
volumes:
- ./public:/app/public
- ./data:/app/apps/web/data
- ./uploads:/app/uploads
networks:
- webgateway
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', r => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
container_name: abaci-green
labels:
traefik.enable: "true"
traefik.http.routers.abaci.rule: "Host(`abaci.one`)"
traefik.http.routers.abaci.entrypoints: websecure
traefik.http.routers.abaci.tls: "true"
traefik.http.routers.abaci.tls.certresolver: myresolver
traefik.http.routers.abaci.middlewares: hsts@docker
traefik.http.routers.abaci.service: abaci
traefik.http.routers.abaci-http.rule: "Host(`abaci.one`)"
traefik.http.routers.abaci-http.entrypoints: web
traefik.http.routers.abaci-http.middlewares: redirect-https@docker
traefik.http.services.abaci.loadbalancer.server.port: "3000"
traefik.http.services.abaci.loadbalancer.healthcheck.path: /
traefik.http.services.abaci.loadbalancer.healthcheck.interval: 10s
traefik.http.middlewares.redirect-https.redirectscheme.scheme: https
traefik.http.middlewares.redirect-https.redirectscheme.permanent: "true"
traefik.http.middlewares.hsts.headers.stsSeconds: "63072000"
traefik.http.middlewares.hsts.headers.stsIncludeSubdomains: "true"
traefik.http.middlewares.hsts.headers.stsPreload: "true"
docker-compose-watcher.watch: "1"
docker-compose-watcher.dir: /volume1/homes/antialias/projects/abaci.one
docker-compose-watcher.file: docker-compose.green.yaml
networks:
webgateway:
external: true

View File

@ -1,49 +1,70 @@
version: "3.8"
# Main docker-compose file - source of truth
#
# Run `./generate-compose.sh` to generate docker-compose.blue.yaml and
# docker-compose.green.yaml for compose-updater (requires yq).
#
# compose-updater needs separate files so it restarts containers independently,
# giving us zero-downtime deployments.
x-app: &app
image: ghcr.io/antialias/soroban-abacus-flashcards:latest
restart: unless-stopped
env_file:
- .env
volumes:
- ./public:/app/public
- ./data:/app/apps/web/data
- ./uploads:/app/uploads
networks:
- webgateway
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', r => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
x-traefik-labels: &traefik-labels
traefik.enable: "true"
traefik.http.routers.abaci.rule: "Host(`abaci.one`)"
traefik.http.routers.abaci.entrypoints: websecure
traefik.http.routers.abaci.tls: "true"
traefik.http.routers.abaci.tls.certresolver: myresolver
traefik.http.routers.abaci.middlewares: hsts@docker
traefik.http.routers.abaci.service: abaci
traefik.http.routers.abaci-http.rule: "Host(`abaci.one`)"
traefik.http.routers.abaci-http.entrypoints: web
traefik.http.routers.abaci-http.middlewares: redirect-https@docker
traefik.http.services.abaci.loadbalancer.server.port: "3000"
traefik.http.services.abaci.loadbalancer.healthcheck.path: /
traefik.http.services.abaci.loadbalancer.healthcheck.interval: 10s
traefik.http.middlewares.redirect-https.redirectscheme.scheme: https
traefik.http.middlewares.redirect-https.redirectscheme.permanent: "true"
traefik.http.middlewares.hsts.headers.stsSeconds: "63072000"
traefik.http.middlewares.hsts.headers.stsIncludeSubdomains: "true"
traefik.http.middlewares.hsts.headers.stsPreload: "true"
services:
# ────────────────────────────────────
# Soroban Abacus Flashcards Web App (apps/web)
# ────────────────────────────────────
soroban-abacus-flashcards:
image: ghcr.io/antialias/soroban-abacus-flashcards:latest
container_name: soroban-abacus-flashcards
restart: unless-stopped
env_file:
- .env
volumes:
- ./public:/app/public
- ./data:/app/apps/web/data
- ./uploads:/app/uploads
blue:
<<: *app
container_name: abaci-blue
labels:
# ── Traefik Routers ───────────────────────────────────
# HTTPS router
- "traefik.enable=true"
- "traefik.http.routers.abaci.rule=Host(`abaci.one`)"
- "traefik.http.routers.abaci.entrypoints=websecure"
- "traefik.http.routers.abaci.tls=true"
- "traefik.http.routers.abaci.tls.certresolver=myresolver"
- "traefik.http.routers.abaci.middlewares=hsts@docker"
<<: *traefik-labels
docker-compose-watcher.watch: "1"
docker-compose-watcher.dir: /volume1/homes/antialias/projects/abaci.one
docker-compose-watcher.file: docker-compose.blue.yaml
# HTTP → HTTPS redirect router
- "traefik.http.routers.abaci-http.rule=Host(`abaci.one`)"
- "traefik.http.routers.abaci-http.entrypoints=web"
- "traefik.http.routers.abaci-http.middlewares=redirect-https@docker"
green:
<<: *app
container_name: abaci-green
labels:
<<: *traefik-labels
docker-compose-watcher.watch: "1"
docker-compose-watcher.dir: /volume1/homes/antialias/projects/abaci.one
docker-compose-watcher.file: docker-compose.green.yaml
# ── Abaci service definition
- "traefik.http.services.abaci.loadbalancer.server.port=3000"
# ── Shared middlewares
- "traefik.http.middlewares.redirect-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.redirect-https.redirectscheme.permanent=true"
- "traefik.http.middlewares.hsts.headers.stsSeconds=63072000"
- "traefik.http.middlewares.hsts.headers.stsIncludeSubdomains=true"
- "traefik.http.middlewares.hsts.headers.stsPreload=true"
networks:
- webgateway
# ────────────────────────────────────
# DDNS Updater (Porkbun for abaci.one)
# ────────────────────────────────────
ddns-updater:
image: qmcgaw/ddns-updater:latest
container_name: ddns-updater
@ -52,30 +73,11 @@ services:
environment:
- TZ=America/Chicago
ports:
- "8000:8000" # optional web UI
- "8000:8000"
restart: unless-stopped
networks:
- webgateway
# ────────────────────────────────────
# Watchtower (auto-update)
# ────────────────────────────────────
watchtower:
image: containrrr/watchtower
container_name: watchtower
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
command: --interval 300 soroban-abacus-flashcards ddns-updater
environment:
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_ROLLING_RESTART=true
networks:
- webgateway
# ──────────────────────────────────────
# Networks & Volumes
# ──────────────────────────────────────
networks:
webgateway:
external: true # same network Traefik lives on
external: true

View File

@ -0,0 +1,48 @@
#!/bin/bash
#
# Generate docker-compose.blue.yaml and docker-compose.green.yaml
# from the main docker-compose.yaml
#
# compose-updater needs separate files to restart containers independently.
# This script extracts each service into its own file.
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Check for yq
if ! command -v yq &> /dev/null; then
echo "Error: yq is required but not installed."
echo "Install with: brew install yq"
exit 1
fi
echo "Generating docker-compose.blue.yaml..."
yq eval --yaml-fix-merge-anchor-to-spec '
explode(.) |
{
"version": .version,
"services": {"blue": .services.blue},
"networks": .networks
}
' docker-compose.yaml > docker-compose.blue.yaml
echo "Generating docker-compose.green.yaml..."
yq eval --yaml-fix-merge-anchor-to-spec '
explode(.) |
{
"version": .version,
"services": {"green": .services.green},
"networks": .networks
}
' docker-compose.yaml > docker-compose.green.yaml
echo "Done!"
echo ""
echo "Generated files:"
ls -la docker-compose.blue.yaml docker-compose.green.yaml
echo ""
echo "Verify no secrets were included:"
grep -l "sk-" docker-compose.*.yaml 2>/dev/null && echo "WARNING: Found secrets!" || echo "OK - no secrets found"