CI/CD Deployment Guide
Last Updated: 2026-01-25
Audience: Developers setting up automated deployment to production
Overview
This guide covers the automated CI/CD deployment pipeline for ARC // OS to a Linux server using Docker. The deployment workflow builds Docker images, pushes them to GitHub Container Registry, and deploys to the production server at arc.corgicy.com.
Architecture
Services Deployed
- Marketing Website - Static marketing site (root
/) - Main Application - React web app (at
/app) - Backend API - Fastify server (at
/api) - Documentation Site - Docusaurus docs (at
/docs) - PostgreSQL Database - Data persistence
- Traefik Reverse Proxy - SSL termination (Let's Encrypt) and routing; only service that publishes host ports 80 and 443. All HTTP/HTTPS enters here; Traefik routes internally to file-server and server.
- File server (nginx) - Single image built in CI with web app, marketing, and docs static assets; serves
/app,/marketing,/docs; no ports published (internal only; reached via Traefik onarcos-network).
Important: On the host, nothing else (e.g. host nginx or Apache) should bind to 80/443. If something else is on 80, you will see "Welcome to nginx!" or connection issues; see Port 80 shows "Welcome to nginx!". 8. Backup Service - Automated backups (on-demand)
Deployment Flow
GitHub Push → CI Tests → Build Images → Push to Registry → Deploy to Server
Accessing the app
Once deployed, use these URLs (replace with your domain if different):
| URL | What |
|---|---|
| https://arcos.corgicy.com/ | Marketing / landing page |
| https://arcos.corgicy.com/app | Main web app (login, dashboard, etc.) |
| https://arcos.corgicy.com/docs/ | Documentation (Docusaurus) |
| https://arcos.corgicy.com/api | Backend API |
| https://arcos.corgicy.com/mcp | Claude MCP connector |
| https://arcos.corgicy.com/uploads | User uploads (images, files; served by backend) |
Docs are served at https://arcos.corgicy.com/docs/ from the file-server image (Docusaurus static build baked in CI). /docs is routed by Traefik to file-server.
HTTP is redirected to HTTPS. If you see "TRAEFIK DEFAULT CERT", see Let's Encrypt / ACME below.
Prerequisites
Server Requirements
- Linux server (Ubuntu 20.04+ recommended)
- Docker 20.10+ installed
- Docker Compose 2.0+ installed
- SSH access configured
- Domain configured (arc.corgicy.com)
- SSL certificates (Let's Encrypt recommended)
GitHub Secrets Required
Configure these secrets in GitHub repository settings:
DEPLOY_SSH_KEY- Private SSH key for server accessDEPLOY_HOST- Server hostname or IP addressDEPLOY_USER- SSH username (e.g.,deployorubuntu)
Server Setup
-
Create deployment directory:
sudo mkdir -p /opt/arcos/{docker,ssl,env}
sudo chown $USER:$USER /opt/arcos -R -
Create environment file (source of truth; deploy adds image tags into
env/.env):cd /opt/arcos
# Copy from repo example, or create from scratch (see env/.env.example)
cp /path/to/arcos/env/.env.example .env
nano .env # fill POSTGRES_PASSWORD, JWT_SECRET, CORS_ORIGIN, etc. Do NOT add SERVER_IMAGE or FILE_SERVER_IMAGE
chmod 600 .envDeploy uses only
/opt/arcos/.envas source: it overwritesenv/.envwith the contents of.env(stripping any image vars) plusSERVER_IMAGEandFILE_SERVER_IMAGE. You never editenv/.envby hand; you maintain only.env. If.envis missing on the server, the deploy step fails with a clear error.Migrating: If you had
env/.env.prodor a bloatedenv/.env, create/opt/arcos/.envonce with your config (noSERVER_IMAGEorFILE_SERVER_IMAGE). Example:grep -v '^SERVER_IMAGE=\|^FILE_SERVER_IMAGE=\|^WEB_IMAGE=\|^MARKETING_IMAGE=\|^DOCS_IMAGE=' env/.env | head -n 25 > .env(keep one block). Then deploy will keep writing a cleanenv/.envfrom.env+ image tags. -
Setup SSL certificates:
# Install Certbot
sudo apt-get update
sudo apt-get install certbot
# Generate certificates
sudo certbot certonly --standalone -d arc.corgicy.com
# Copy certificates to deployment directory
sudo cp /etc/letsencrypt/live/arc.corgicy.com/fullchain.pem /opt/arcos/ssl/
sudo cp /etc/letsencrypt/live/arc.corgicy.com/privkey.pem /opt/arcos/ssl/
sudo chown $USER:$USER /opt/arcos/ssl -R -
Setup automated certificate renewal:
# Create renewal script
sudo cat > /etc/cron.monthly/renew-arcos-ssl << 'EOF'
#!/bin/bash
certbot renew --quiet
cp /etc/letsencrypt/live/arc.corgicy.com/fullchain.pem /opt/arcos/ssl/
cp /etc/letsencrypt/live/arc.corgicy.com/privkey.pem /opt/arcos/ssl/
docker compose --env-file /opt/arcos/env/.env -f /opt/arcos/docker-compose.yml restart traefik
EOF
sudo chmod +x /etc/cron.monthly/renew-arcos-ssl
GitHub Actions Workflow
Workflow File
The deployment workflow is located at .github/workflows/deploy.yml.
Trigger Conditions
- Automatic: Pushes to
mainbranch (ignores markdown and docs-only changes) - Manual: Can be triggered manually via GitHub Actions UI
Workflow Steps
-
Build Docker Images
- Builds server, web, marketing, and docs images
- Pushes to GitHub Container Registry
- Tags images with commit SHA and
latest
-
Deploy to Server
- Connects to server via SSH
- Copies docker-compose and nginx config
- Pulls latest images
- Runs database migrations
- Restarts services
- Verifies deployment
Image Tags
Images are tagged as:
ghcr.io/corgicy/arcos-server:<commit-sha>ghcr.io/corgicy/arcos-web:<commit-sha>ghcr.io/corgicy/arcos-marketing:<commit-sha>ghcr.io/corgicy/arcos-docs:<commit-sha>
Latest images are also tagged with :latest.
Production Docker Compose
Configuration File
Production configuration is in docker-compose.yml; Traefik and file-server use profile prod (deploy runs with --profile prod).
Key Features
- Pre-built images - Uses images from GitHub Container Registry
- Environment variables - Loaded from
env/.env(generated from/opt/arcos/.env+ image tags by deploy) - Health checks - All services have health checks
- Restart policies - Services restart automatically
- Network isolation - Services on dedicated network
- Volume persistence - Data persisted across restarts
Service Configuration
Database:
.envhas onlyPOSTGRES_USER,POSTGRES_PASSWORD,POSTGRES_DB(noDATABASE_URL). Compose builds the connection URL from those when needed. SetDATABASE_URLin.envonly if the password contains+,/,@,#, or%(URL-encode it).- Persistent volume for data
- Health checks for dependency management
- Automatic restart
Backend:
- Health check endpoint
- Volume for file uploads
- Environment variables from
env/.env
Frontend Services:
- Nginx serving static files
- Volume mounts for build artifacts
- No build step (uses pre-built images)
Nginx:
- SSL/TLS termination
- Reverse proxy for API
- Rate limiting
- Security headers
- SPA routing for all frontend services
Deployment Process
Automatic Deployment
- Push to main branch
- CI tests run (must pass)
- Images built and pushed
- Deployment triggered
- Services updated on server
Manual Deployment
- Go to Actions tab in GitHub
- Select Deploy to Production workflow
- Click Run workflow
- Select branch (usually
main) - Click Run workflow
Deployment Steps (Automated)
- SSH to server
- Copy configuration files
- Update environment with image tags
- Pull latest images
- Run database migrations
- Restart services
- Verify health checks
Post-Deployment
Verification Checklist
After deployment, verify:
- Marketing site -
https://arc.corgicy.com/ - Main app -
https://arc.corgicy.com/app - Documentation -
https://arc.corgicy.com/docs - API health -
https://arc.corgicy.com/api/health - Backend health -
https://arc.corgicy.com/health
Database Migrations
Migrations run automatically during deployment. To run manually:
ssh user@arc.corgicy.com
cd /opt/arcos
docker compose --env-file env/.env -f docker-compose.yml run --rm server pnpm prisma migrate deploy
Database Seeding
Initial data is seeded automatically during each deploy (after db push). The seed script is idempotent: it skips records that already exist, so running it multiple times does not create duplicates.
To run the full seed manually (e.g. to add new global data or refresh demo user):
docker compose --env-file env/.env -f docker-compose.yml run --rm server pnpm prisma:seed
Gamification-only seed:
docker compose --env-file env/.env -f docker-compose.yml run --rm server pnpm prisma:seed:gamification
Rate limiting
Effective limits are set by server env vars (per time window). Put them in /opt/arcos/.env; deploy copies them into env/.env and docker-compose passes them to the server container:
RATE_LIMIT_MAX– API requests per window (default 1000)RATE_LIMIT_TIME_WINDOW– Window in ms (default 900000 = 15 min)RATE_LIMIT_AUTH_MAX– Auth endpoint requests per window (default 100)RATE_LIMIT_AUTH_TIME_WINDOW– Auth window in msRATE_LIMIT_PUBLIC_API_MAX/RATE_LIMIT_PUBLIC_API_TIME_WINDOW– Public API key limits
Traefik also applies a per-second ceiling (in docker/traefik-dynamic.yml). It is set high (1000/2000 for API, 200/400 for auth) so it does not throttle before the server; the server env vars above are the real limit.
Rollback Procedure
Quick Rollback
-
SSH to server:
ssh user@arc.corgicy.com
cd /opt/arcos -
Rollback image tags (edit generated env/.env; next deploy will overwrite):
cd /opt/arcos
nano env/.env # set SERVER_IMAGE and FILE_SERVER_IMAGE to previous SHA -
Pull and restart:
docker compose --env-file env/.env -f docker-compose.yml pull
docker compose --env-file env/.env -f docker-compose.yml --profile prod up -d
Database Migration Rollback
If a migration causes issues:
# Connect to database
docker compose --env-file env/.env -f docker-compose.yml exec db psql -U arcos -d arcos
# Manually rollback migration (see Prisma migration files)
# Or restore from backup
Monitoring
Health Checks
All services expose health check endpoints:
- Backend:
/healthand/api/health - Nginx: Serves health checks from backend
Logs
View logs for services:
# All services
docker compose --env-file env/.env -f docker-compose.yml logs -f
# Specific service
docker compose --env-file env/.env -f docker-compose.yml logs -f server
docker compose --env-file env/.env -f docker-compose.yml logs -f file-server
Service Status
docker compose --env-file env/.env -f docker-compose.yml ps
Troubleshooting
Deployment Fails
- Check GitHub Actions logs - See which step failed
- SSH to server - Verify server is accessible
- Check Docker - Ensure Docker is running on server
- Verify secrets - Ensure GitHub secrets are correct
Services Not Starting
-
Always run compose from
/opt/arcoswith the env file (otherwise you'll see "POSTGRES_PASSWORD variable is not set" and services may not start):cd /opt/arcos
docker compose --env-file env/.env -f docker-compose.yml logs -
Check logs:
docker compose --env-file env/.env -f docker-compose.yml logs -
Verify environment variables:
cat /opt/arcos/env/.env -
Check health:
docker compose --env-file env/.env -f docker-compose.yml --profile prod ps
SSL Certificate Issues
-
Verify certificates exist:
ls -la /opt/arcos/ssl/ -
Renew certificates:
With Traefik + Let's Encrypt, certificates are renewed automatically. If you use external certs, copy them into the Traefik volume and restart:docker compose --env-file env/.env -f docker-compose.yml --profile prod restart traefik
Let's Encrypt / Traefik default cert
If the browser shows "TRAEFIK DEFAULT CERT" or "Your connection is not private" / ERR_CERT_AUTHORITY_INVALID, Traefik is serving its self-signed cert because ACME hasn't issued one yet or the HTTP challenge failed.
-
Ensure port 80 is reachable from the internet — Let's Encrypt validates via
http://arcos.corgicy.com/.well-known/acme-challenge/.... Our config redirects HTTP→HTTPS except for/.well-known/, so the challenge can succeed. If something else (e.g. host nginx) is on port 80, Traefik won't receive the challenge; see Port 80 shows "Welcome to nginx!". -
Check Traefik logs for ACME errors:
docker compose --env-file env/.env -f docker-compose.yml --profile prod logs traefikLook for lines like
"level=error"and"acme"or"certificate". -
ACME email — In
docker/traefik.ymlthe resolver usesemail: admin@corgicy.com. To use a different address, edit that file (and redeploy thedocker/files to the server) or setACME_EMAILin.envif your Traefik version supports it. -
Restart Traefik after any config change so it retries certificate issuance:
docker compose --env-file env/.env -f docker-compose.yml --profile prod restart traefik -
Cert is Let's Encrypt but browser still says "not private" — Often the server isn't sending the full chain (intermediate missing), or the client doesn't trust the R12 intermediate yet. We set
preferredChain: "ISRG Root X1"so Traefik requests a chain that may be more widely trusted. To force re-issuance with the preferred chain, on the server clear ACME storage and restart Traefik:cd /opt/arcos
docker compose --env-file env/.env -f docker-compose.yml --profile prod stop traefik
docker run --rm -v arcos_letsencrypt_data:/data alpine sh -c "rm -f /data/acme.json"
docker compose --env-file env/.env -f docker-compose.yml --profile prod start traefikWait 1–2 minutes, then reload the site. To verify the chain from your machine:
openssl s_client -connect arcos.corgicy.com:443 -servername arcos.corgicy.com </dev/null 2>/dev/null | openssl x509 -noout -textand check the issuer chain.
Port 80 shows "Welcome to nginx!" (Traefik not receiving traffic)
All traffic should hit Traefik on ports 80/443; Traefik then redirects HTTP→HTTPS and routes to the file-server (nginx) or API. If you see the default nginx page on http://arcos.corgicy.com, something else is bound to port 80 on the host.
-
See what is listening on 80 and 443:
sudo ss -tlnp | grep -E ':80 |:443 '
# or: sudo lsof -i :80 -i :443Only the Traefik container should show for 80 and 443 (e.g.
docker-proxyor the traefik process). -
If host nginx (or another process) is on 80: stop it so Traefik can bind:
sudo systemctl stop nginx
sudo systemctl disable nginx -
Ensure the arcos stack is up and Traefik has 80/443:
cd /opt/arcos
docker compose --env-file env/.env -f docker-compose.yml --profile prod psCheck that
arcos_traefik_prodshows0.0.0.0:80->80/tcpand0.0.0.0:443->443/tcp. -
Expected flow:
http://arcos.corgicy.com→ Traefik (80) → redirect tohttps://arcos.corgicy.com→ Traefik (443) → routes to file-server or server.
Backend server not running (only db in docker ps -a)
-
Backup service is under profile
backupsoup -ddoes not try to build it on the server (no build context there). Main services (server, file-server, traefik, worker) should start. -
Start the stack manually (always from
/opt/arcoswithenv/.env; use--profile prodfor Traefik and file-server):cd /opt/arcos
docker compose --env-file env/.env -f docker-compose.yml --profile prod up -d --remove-orphans
docker compose --env-file env/.env -f docker-compose.yml --profile prod ps -a -
If server exits immediately: check
docker compose --env-file env/.env -f docker-compose.yml logs server. If you see "password authentication failed for user arcos", the DB was initialized with a different password; see Database Connection Issues below.
Database Connection Issues
-
Verify database is running:
docker compose --env-file env/.env -f docker-compose.yml --profile prod ps db -
Check connection:
.envhasPOSTGRES_USER,POSTGRES_PASSWORD,POSTGRES_DB; compose buildsDATABASE_URLfrom those. If you setDATABASE_URLin.env(e.g. for special chars in password), it will be inenv/.envafter deploy:grep -E 'POSTGRES_|DATABASE_URL' /opt/arcos/env/.env -
Test connection:
docker compose --env-file env/.env -f docker-compose.yml exec server pnpm prisma db push --dry-run -
If you see "password authentication failed for user arcos": The DB was initialized with a different
POSTGRES_PASSWORDthan in your current.env. Either:- Recreate the DB (data loss):
cd /opt/arcos && docker compose --env-file env/.env -f docker-compose.yml --profile prod down && docker volume rm arcos_postgres_data(volume name may vary; checkdocker volume ls). Thenup -d --profile prodagain so Postgres initializes with the password from.env. - Or change the password inside Postgres to match
.env:docker exec arcos_db_prod psql -U arcos -d arcos -c "ALTER USER arcos PASSWORD 'your-password-from-env';"(use the value fromPOSTGRES_PASSWORDin.env).
- Recreate the DB (data loss):
Table does not exist (P2021 / "The table `public.users` does not exist")
The database is empty or the schema was never applied.
-
Try migrations first (if the project has migration files in the image):
cd /opt/arcos
docker compose --env-file env/.env -f docker-compose.yml run --rm server pnpm prisma migrate deploy -
If you see "No migration found in prisma/migrations" — migrations are not in the repo (they are gitignored), so the image has no migration files. Create the schema from
schema.prismawith db push:cd /opt/arcos
docker compose --env-file env/.env -f docker-compose.yml run --rm server pnpm prisma db pushThis creates all tables (users, etc.) from the current schema. Then restart:
docker compose --env-file env/.env -f docker-compose.yml --profile prod restart server worker -
Long term: To use
migrate deployin production, add migrations to the repo: create an initial migration locally (pnpm prisma migrate dev --name init), removeserver/prisma/migrations/from.gitignore, and commit the migrations folder.
Security Considerations
SSH Key Security
- Use dedicated deploy user with limited permissions
- Restrict SSH key to deployment commands only
- Rotate keys regularly
Environment Variables
- Never commit
env/.envto repository - Use strong passwords for database
- Rotate JWT_SECRET periodically
- Limit file permissions:
chmod 600 /opt/arcos/env/.env
Container Security
- Run as non-root where possible
- Keep images updated (security patches)
- Use specific image tags (not
latestin production) - Limit container resources (CPU, memory)
Best Practices
- Test deployments - Test in staging before production
- Monitor deployments - Watch logs during deployment
- Backup before deploy - Always backup database before major updates
- Gradual rollout - Consider blue-green deployment for zero downtime
- Document changes - Keep deployment notes for each release
Future Enhancements
- Blue-green deployment - Zero-downtime deployments
- Health check automation - Automatic rollback on health check failure
- Deployment notifications - Slack/Discord notifications
- Staging environment - Separate staging deployment
- Database migration testing - Test migrations before production
- Performance monitoring - Integrate monitoring tools