# Staging Environment Setup Guide This document describes the complete staging environment setup for ManaCore apps on Hetzner VPS with HTTPS via Caddy reverse proxy. ## Overview | Component | Details | |-----------|---------| | **Server** | Hetzner VPS (46.224.108.214) | | **Domain** | manacore.ai (Namecheap) | | **Reverse Proxy** | Caddy (auto-SSL via Let's Encrypt) | | **Container Runtime** | Docker Compose | | **SSH Access** | `ssh -i ~/.ssh/hetzner_deploy_key deploy@46.224.108.214` | ## Architecture ``` ┌─────────────────────────────────────────────┐ │ Hetzner VPS (46.224.108.214) │ │ │ Internet │ ┌─────────────────────────────────────┐ │ │ │ │ Caddy (ports 80/443) │ │ │ │ │ Auto-SSL via Let's Encrypt │ │ ▼ │ └──────────────┬──────────────────────┘ │ ┌──────────────┐ │ │ │ │ Namecheap │ │ ▼ │ │ DNS Records │────────────────────│ ┌─────────────────────────────────────┐ │ │ │ │ │ Docker Compose Services │ │ │ *.staging │ │ │ │ │ │ A → IP │ │ │ mana-core-auth:3001 │ │ └──────────────┘ │ │ chat-web:3000 / chat-backend:3002 │ │ │ │ clock-web:5187 / clock-backend:3017│ │ │ │ calendar-web:5186 / calendar-api:3016│ │ │ │ todo-web:5188 / todo-backend:3018 │ │ │ │ manacore-web:5173 │ │ │ │ postgres:5432 / redis:6379 │ │ │ └─────────────────────────────────────┘ │ └─────────────────────────────────────────────┘ ``` ## Domain Mapping ### DNS Configuration (Namecheap) | Type | Host | Value | TTL | |------|------|-------|-----| | A | `staging` | 46.224.108.214 | Automatic | | A | `*.staging` | 46.224.108.214 | Automatic | The wildcard record `*.staging` enables all subdomains like `auth.staging.manacore.ai`, `clock.staging.manacore.ai`, etc. ### Staging URLs | Service | URL | Internal Port | |---------|-----|---------------| | **Auth** | https://auth.staging.manacore.ai | 3001 | | **ManaCore Web** | https://staging.manacore.ai | 5173 | | **Chat Web** | https://chat.staging.manacore.ai | 3000 | | **Chat API** | https://chat-api.staging.manacore.ai | 3002 | | **Clock Web** | https://clock.staging.manacore.ai | 5187 | | **Clock API** | https://clock-api.staging.manacore.ai | 3017 | | **Calendar Web** | https://calendar.staging.manacore.ai | 5186 | | **Calendar API** | https://calendar-api.staging.manacore.ai | 3016 | | **Todo Web** | https://todo.staging.manacore.ai | 5188 | | **Todo API** | https://todo-api.staging.manacore.ai | 3018 | ## Caddy Reverse Proxy ### Installation (One-time setup) ```bash # SSH into server ssh -i ~/.ssh/hetzner_deploy_key deploy@46.224.108.214 # Create Caddy data directory mkdir -p ~/caddy_data ~/caddy_config # Run Caddy container docker run -d \ --name caddy \ --network host \ --restart unless-stopped \ -v ~/Caddyfile:/etc/caddy/Caddyfile \ -v ~/caddy_data:/data \ -v ~/caddy_config:/config \ caddy:2-alpine ``` ### Configuration The Caddyfile is stored at: - **Server**: `~/Caddyfile` - **Repo**: `docker/caddy/Caddyfile.staging` ```caddyfile # ManaCore Staging Reverse Proxy auth.staging.manacore.ai { reverse_proxy localhost:3001 } chat.staging.manacore.ai { reverse_proxy localhost:3000 } chat-api.staging.manacore.ai { reverse_proxy localhost:3002 } staging.manacore.ai { reverse_proxy localhost:5173 } calendar.staging.manacore.ai { reverse_proxy localhost:5186 } calendar-api.staging.manacore.ai { reverse_proxy localhost:3016 } clock.staging.manacore.ai { reverse_proxy localhost:5187 } clock-api.staging.manacore.ai { reverse_proxy localhost:3017 } todo.staging.manacore.ai { reverse_proxy localhost:5188 } todo-api.staging.manacore.ai { reverse_proxy localhost:3018 } ``` ### Updating Caddy Configuration ```bash # Copy updated config to server scp -i ~/.ssh/hetzner_deploy_key docker/caddy/Caddyfile.staging deploy@46.224.108.214:~/Caddyfile # Reload Caddy (no downtime) ssh -i ~/.ssh/hetzner_deploy_key deploy@46.224.108.214 "docker exec caddy caddy reload --config /etc/caddy/Caddyfile" ``` ### Caddy Management Commands ```bash # View logs docker logs caddy -f # Restart Caddy docker restart caddy # Check Caddy status docker exec caddy caddy validate --config /etc/caddy/Caddyfile ``` ## SvelteKit Runtime Environment Variables ### The Problem SvelteKit's `$env/static/public` variables are replaced at **build time**. When Docker images are built in CI, the environment variables are baked into the JavaScript bundles. This means containers cannot use different URLs for different environments. ### The Solution Use `$env/dynamic/private` in `hooks.server.ts` to read environment variables at **runtime**, then inject them into the HTML for client-side access. ### Implementation Each SvelteKit web app has a `hooks.server.ts` that: 1. Reads `_CLIENT` environment variables at runtime 2. Injects them into the HTML via ``; return html.replace('', `${envScript}`); }, }); }; ``` ### Environment Variable Pattern Each web app container receives two sets of URLs: | Variable | Purpose | Example | |----------|---------|---------| | `PUBLIC_BACKEND_URL` | Server-side (Docker network) | `http://clock-backend:3017` | | `PUBLIC_BACKEND_URL_CLIENT` | Client-side (browser) | `https://clock-api.staging.manacore.ai` | | `PUBLIC_MANA_CORE_AUTH_URL` | Server-side auth | `http://mana-core-auth:3001` | | `PUBLIC_MANA_CORE_AUTH_URL_CLIENT` | Client-side auth | `https://auth.staging.manacore.ai` | ## Docker Compose Configuration ### File Locations | File | Purpose | |------|---------| | `docker-compose.staging.yml` | Staging configuration (repo) | | `~/manacore-staging/docker-compose.yml` | Server deployment | ### Key Configuration Sections **Web App Environment Variables:** ```yaml clock-web: environment: NODE_ENV: staging PORT: 5187 # Server-side URLs (Docker internal network) PUBLIC_BACKEND_URL: http://clock-backend:3017 PUBLIC_MANA_CORE_AUTH_URL: http://mana-core-auth:3001 # Client-side URLs (browser access via HTTPS) PUBLIC_BACKEND_URL_CLIENT: https://clock-api.staging.manacore.ai PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.staging.manacore.ai ``` **Backend CORS Configuration:** ```yaml clock-backend: environment: CORS_ORIGINS: https://clock.staging.manacore.ai,https://staging.manacore.ai,http://localhost:5187 ``` **Auth Service CORS:** ```yaml mana-core-auth: environment: CORS_ORIGINS: https://chat.staging.manacore.ai,https://staging.manacore.ai,https://calendar.staging.manacore.ai,https://clock.staging.manacore.ai,https://todo.staging.manacore.ai,http://localhost:3000,http://localhost:5173 ``` ### Syncing Configuration to Server ```bash # Copy docker-compose to server scp -i ~/.ssh/hetzner_deploy_key docker-compose.staging.yml deploy@46.224.108.214:~/manacore-staging/docker-compose.yml # Recreate containers with new config ssh -i ~/.ssh/hetzner_deploy_key deploy@46.224.108.214 "cd ~/manacore-staging && docker compose up -d --force-recreate" ``` ## Deployment Workflow ### CI/CD Pipeline The GitHub Actions workflow (`.github/workflows/cd-staging.yml`): 1. Builds Docker images on push to `dev` branch 2. Pushes images to GitHub Container Registry (ghcr.io) 3. SSHs into staging server 4. Pulls latest images 5. Restarts containers ### Manual Deployment ```bash # 1. Build and push images (from local) docker build -t ghcr.io/memo-2023/clock-web:latest -f apps/clock/apps/web/Dockerfile . docker push ghcr.io/memo-2023/clock-web:latest # 2. SSH into server ssh -i ~/.ssh/hetzner_deploy_key deploy@46.224.108.214 # 3. Pull and restart cd ~/manacore-staging docker compose pull docker compose up -d --force-recreate ``` ### Updating Environment Variables 1. Edit `docker-compose.staging.yml` locally 2. Copy to server: `scp -i ~/.ssh/hetzner_deploy_key docker-compose.staging.yml deploy@46.224.108.214:~/manacore-staging/docker-compose.yml` 3. Recreate affected containers: `docker compose up -d --force-recreate ` ## Troubleshooting ### Mixed Content Errors **Symptom:** Browser console shows "Mixed Content: The page was loaded over HTTPS, but requested an insecure resource" **Cause:** Client-side JavaScript is calling HTTP URLs instead of HTTPS **Solution:** 1. Check `_CLIENT` environment variables in docker-compose.yml 2. Ensure they use `https://` staging domains 3. Recreate web containers: `docker compose up -d --force-recreate ` ### CORS Errors **Symptom:** Browser console shows "Access-Control-Allow-Origin" errors **Cause:** Backend CORS_ORIGINS doesn't include the HTTPS staging domain **Solution:** 1. Add the HTTPS domain to `CORS_ORIGINS` in docker-compose.yml 2. Recreate backend containers 3. See [CORS Configuration Guide](./CORS_CONFIGURATION_GUIDE.md) for detailed instructions ### Caddy SSL Certificate Issues **Symptom:** Browser shows SSL certificate warning **Solution:** ```bash # Check Caddy logs docker logs caddy # Force certificate renewal docker exec caddy caddy reload --config /etc/caddy/Caddyfile ``` ### Container Health Check Failures **Symptom:** Container shows "unhealthy" status **Solution:** ```bash # Check container logs docker logs # Check health status docker inspect | grep -A 20 Health ``` ### SvelteKit Pre-Compressed Config Files (IMPORTANT) **Symptom:** Browser calls `localhost:3001` instead of staging URLs, even though: - Server logs show correct config was generated - `docker exec cat .../config.json` shows correct values - `curl https://app.staging.manacore.ai/config.json` shows wrong (localhost) values **Root Cause:** SvelteKit pre-compresses static files during build: - `config.json` - raw file (overwritten by entrypoint ✓) - `config.json.br` - Brotli compressed (stale from build ✗) - `config.json.gz` - Gzip compressed (stale from build ✗) When browsers send `Accept-Encoding: gzip, br`, SvelteKit serves the pre-compressed versions, which contain the old localhost values from build time. **Solution (Permanent):** The `docker-entrypoint.sh` must delete pre-compressed files: ```bash # After generating config.json, remove stale compressed versions rm -f /app/apps//apps/web/build/client/config.json.br rm -f /app/apps//apps/web/build/client/config.json.gz ``` **Solution (Quick Fix):** For immediate fix without redeploying: ```bash # Remove compressed files and restart docker exec -web-staging rm -f /app/apps//apps/web/build/client/config.json.br /app/apps//apps/web/build/client/config.json.gz docker restart -web-staging docker restart caddy # Clear any Caddy cache ``` **Diagnosis Commands:** ```bash # Check if pre-compressed files exist docker exec -web-staging ls -la /app/apps//apps/web/build/client/config.json* # Test what browser receives (from local machine, not server) curl https://.staging.manacore.ai/config.json # Compare file content vs HTTP response docker exec -web-staging cat /app/apps//apps/web/build/client/config.json # vs curl https://.staging.manacore.ai/config.json ``` ### Caddy Response Caching **Symptom:** After fixing config.json on the server, `curl` still returns old values **Solution:** ```bash # Restart Caddy to clear any cached responses docker restart caddy ``` ### Disk Space Full (Docker) **Symptom:** CI/CD deployment fails with "no space left on device" **Cause:** Docker images, containers, and build cache accumulating over time **Solution (Immediate):** ```bash ssh -i ~/.ssh/hetzner_deploy_key deploy@46.224.108.214 # Check disk usage df -h # Clean up all unused Docker resources docker system prune -af --volumes docker builder prune -af ``` **Solution (Preventive):** Set up a weekly cleanup cronjob: ```bash crontab -e # Add this line (runs every Sunday at 3 AM): 0 3 * * 0 docker system prune -af --volumes >> /home/deploy/docker-cleanup.log 2>&1 ``` ## Adding a New App to Staging ### 1. Update DNS (if needed) If using a new subdomain pattern, update Namecheap DNS. The `*.staging` wildcard should cover most cases. ### 2. Update Caddyfile Add entries for web and API: ```caddyfile newapp.staging.manacore.ai { reverse_proxy localhost: } newapp-api.staging.manacore.ai { reverse_proxy localhost: } ``` ### 3. Update docker-compose.staging.yml Add the new services with proper environment variables: ```yaml newapp-web: image: ghcr.io/memo-2023/newapp-web:latest environment: PUBLIC_BACKEND_URL: http://newapp-backend: PUBLIC_MANA_CORE_AUTH_URL: http://mana-core-auth:3001 PUBLIC_BACKEND_URL_CLIENT: https://newapp-api.staging.manacore.ai PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.staging.manacore.ai ports: - ":" ``` ### 4. Create docker-entrypoint.sh (CRITICAL) Create `apps//apps/web/docker-entrypoint.sh`: ```bash #!/bin/sh set -e echo "🔧 Generating runtime configuration..." # Environment variables with development defaults BACKEND_URL=${BACKEND_URL:-"http://localhost:"} AUTH_URL=${AUTH_URL:-"http://localhost:3001"} echo "📝 Config values:" echo " BACKEND_URL: $BACKEND_URL" echo " AUTH_URL: $AUTH_URL" # Generate config.json from environment variables cat > /app/apps//apps/web/build/client/config.json </apps/web/build/client/config.json" cat /app/apps//apps/web/build/client/config.json # CRITICAL: Remove pre-compressed versions (SvelteKit serves these instead of raw file) rm -f /app/apps//apps/web/build/client/config.json.br rm -f /app/apps//apps/web/build/client/config.json.gz echo "🗑️ Removed stale pre-compressed config files" echo "🚀 Starting web app..." exec "$@" ``` Make sure the Dockerfile copies and uses this entrypoint: ```dockerfile COPY apps//apps/web/docker-entrypoint.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/docker-entrypoint.sh ENTRYPOINT ["docker-entrypoint.sh"] CMD ["node", "build"] ``` ### 5. Create static/config.json for local development Create `apps//apps/web/static/config.json` for local dev (this gets overwritten at runtime in Docker): ```json { "BACKEND_URL": "http://localhost:", "AUTH_URL": "http://localhost:3001" } ``` ### 6. Implement runtime.ts config loader Create `apps//apps/web/src/lib/config/runtime.ts`: ```typescript import { browser, dev } from '$app/environment'; export interface RuntimeConfig { BACKEND_URL: string; AUTH_URL: string; } const DEV_CONFIG: RuntimeConfig = { BACKEND_URL: 'http://localhost:', AUTH_URL: 'http://localhost:3001', }; let cachedConfig: RuntimeConfig | null = null; export async function getConfig(): Promise { if (!browser) return DEV_CONFIG; if (cachedConfig) return cachedConfig; try { const res = await fetch('/config.json'); if (!res.ok) { if (dev) return DEV_CONFIG; throw new Error(`Failed to load config: ${res.status}`); } cachedConfig = await res.json(); return cachedConfig!; } catch (error) { if (dev) return DEV_CONFIG; throw error; } } export async function getAuthUrl(): Promise { const config = await getConfig(); return config.AUTH_URL; } export async function getBackendUrl(): Promise { const config = await getConfig(); return config.BACKEND_URL; } ``` ### 7. Deploy 1. Sync Caddyfile: `scp ... Caddyfile.staging deploy@server:~/Caddyfile` 2. Reload Caddy: `docker exec caddy caddy reload --config /etc/caddy/Caddyfile` 3. Sync docker-compose: `scp ... docker-compose.staging.yml deploy@server:~/manacore-staging/docker-compose.yml` 4. Deploy containers: `docker compose up -d` ## Quick Reference Commands ```bash # SSH into server ssh -i ~/.ssh/hetzner_deploy_key deploy@46.224.108.214 # View all containers docker ps # View container logs docker logs -f # Restart a container docker restart # Recreate containers with new config cd ~/manacore-staging && docker compose up -d --force-recreate # Check Caddy SSL certificates docker exec caddy caddy validate --config /etc/caddy/Caddyfile # Test HTTPS endpoint curl -s https://auth.staging.manacore.ai/api/v1/health # Check container env vars docker exec printenv | grep -E 'CLIENT|CORS' ``` ## Related Documentation - [CORS Configuration Guide](./CORS_CONFIGURATION_GUIDE.md) - Solving CORS issues once and for all - [Local Development Guide](./LOCAL_DEVELOPMENT.md) - [CI/CD Deployment Guide](./DEPLOYMENT.md) - [Environment Variables](./ENVIRONMENT_VARIABLES.md)