Barazo Docker Compose templates for self-hosting
barazo.forum
1name: Deploy to Staging
2
3on:
4 repository_dispatch:
5 types: [deploy-staging]
6 workflow_dispatch:
7 inputs:
8 api_ref:
9 description: "barazo-api branch/tag (default: main)"
10 required: false
11 default: "main"
12 web_ref:
13 description: "barazo-web branch/tag (default: main)"
14 required: false
15 default: "main"
16 push:
17 branches: [main]
18 paths:
19 - "docker-compose*.yml"
20 - "Caddyfile"
21 - ".env.example"
22
23env:
24 REGISTRY: ghcr.io
25 DEPLOY_PATH: /opt/barazo
26 COMPOSE_FILES: "-f docker-compose.yml -f docker-compose.staging.yml"
27
28concurrency:
29 group: staging-deploy
30 cancel-in-progress: false
31
32jobs:
33 # ======================================================================
34 # Job 1: Build and push Docker images to GHCR
35 # ======================================================================
36 build:
37 name: Build Images
38 runs-on: ubuntu-latest
39 permissions:
40 contents: read
41 packages: write
42 timeout-minutes: 15
43 outputs:
44 api_ref: ${{ steps.refs.outputs.api_ref }}
45 web_ref: ${{ steps.refs.outputs.web_ref }}
46 trigger: ${{ steps.refs.outputs.trigger }}
47
48 steps:
49 - name: Determine checkout refs
50 id: refs
51 run: |
52 if [ "${{ github.event_name }}" = "repository_dispatch" ]; then
53 API_REF="${{ github.event.client_payload.api_ref || 'main' }}"
54 WEB_REF="${{ github.event.client_payload.web_ref || 'main' }}"
55 TRIGGER="${{ github.event.client_payload.trigger_repo || 'manual' }}"
56 elif [ "${{ github.event_name }}" = "push" ]; then
57 API_REF="main"
58 WEB_REF="main"
59 TRIGGER="barazo-deploy"
60 else
61 API_REF="${{ inputs.api_ref }}"
62 WEB_REF="${{ inputs.web_ref }}"
63 TRIGGER="workflow_dispatch"
64 fi
65 echo "api_ref=$API_REF" >> "$GITHUB_OUTPUT"
66 echo "web_ref=$WEB_REF" >> "$GITHUB_OUTPUT"
67 echo "trigger=$TRIGGER" >> "$GITHUB_OUTPUT"
68 echo "::notice::Building api@$API_REF web@$WEB_REF (trigger: $TRIGGER)"
69
70 # ------------------------------------------------------------------
71 # Checkout all repos (public -- no PAT needed)
72 # ------------------------------------------------------------------
73 - name: Checkout barazo-deploy
74 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
75
76 - name: Checkout barazo-workspace
77 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
78 with:
79 repository: singi-labs/barazo-workspace
80 path: _workspace
81
82 # Copy workspace root files (lockfile, catalogs) over deploy repo root.
83 # barazo-workspace is the single source of truth for dependency resolution.
84 - name: Sync workspace root files
85 run: |
86 cp _workspace/package.json .
87 cp _workspace/pnpm-lock.yaml .
88 cp _workspace/pnpm-workspace.yaml .
89 cp _workspace/.npmrc .
90 rm -rf _workspace
91
92 - name: Checkout barazo-lexicons
93 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
94 with:
95 repository: singi-labs/barazo-lexicons
96 path: barazo-lexicons
97
98 - name: Checkout barazo-plugins
99 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
100 with:
101 repository: singi-labs/barazo-plugins
102 path: barazo-plugins
103
104 - name: Checkout barazo-api
105 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
106 with:
107 repository: singi-labs/barazo-api
108 ref: ${{ steps.refs.outputs.api_ref }}
109 path: barazo-api
110
111 - name: Checkout barazo-web
112 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
113 with:
114 repository: singi-labs/barazo-web
115 ref: ${{ steps.refs.outputs.web_ref }}
116 path: barazo-web
117
118 # ------------------------------------------------------------------
119 # Docker setup
120 # ------------------------------------------------------------------
121 - name: Set up Docker Buildx
122 uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
123
124 - name: Login to Container Registry
125 uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
126 with:
127 registry: ${{ env.REGISTRY }}
128 username: ${{ github.actor }}
129 password: ${{ secrets.GITHUB_TOKEN }}
130
131 # ------------------------------------------------------------------
132 # Build and push images (amd64 only for staging)
133 # ------------------------------------------------------------------
134 - name: Build and push barazo-api
135 uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
136 with:
137 context: .
138 file: barazo-api/Dockerfile
139 push: true
140 tags: |
141 ${{ env.REGISTRY }}/singi-labs/barazo-api:edge
142 ${{ env.REGISTRY }}/singi-labs/barazo-api:staging-${{ github.run_number }}
143 cache-from: type=gha,scope=api
144 cache-to: type=gha,mode=max,scope=api
145 platforms: linux/amd64
146
147 - name: Build and push barazo-web
148 uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
149 with:
150 context: .
151 file: barazo-web/Dockerfile
152 push: true
153 tags: |
154 ${{ env.REGISTRY }}/singi-labs/barazo-web:edge
155 ${{ env.REGISTRY }}/singi-labs/barazo-web:staging-${{ github.run_number }}
156 cache-from: type=gha,scope=web
157 cache-to: type=gha,mode=max,scope=web
158 platforms: linux/amd64
159
160 # ======================================================================
161 # Job 2: Smoke test -- verify images start and respond to health checks
162 # ======================================================================
163 smoke-test:
164 name: Smoke Test
165 needs: build
166 runs-on: ubuntu-latest
167 permissions:
168 contents: read
169 packages: read
170 timeout-minutes: 5
171
172 env:
173 POSTGRES_USER: barazo
174 POSTGRES_PASSWORD: ci_smoke_test
175 POSTGRES_DB: barazo
176 VALKEY_PASSWORD: ci_smoke_test
177 DATABASE_URL: postgresql://barazo:ci_smoke_test@postgres:5432/barazo
178 TAP_ADMIN_PASSWORD: ci_smoke_test
179 SESSION_SECRET: ci_session_secret_not_real_extend_to_32
180 RELAY_URL: wss://bsky.network
181 COMMUNITY_DID: did:plc:ci-smoke-test
182 COMMUNITY_NAME: CI Smoke Test
183 COMMUNITY_DOMAIN: ":80"
184 COMMUNITY_MODE: single
185 OAUTH_CLIENT_ID: https://ci-smoke.barazo.forum/client-metadata.json
186 OAUTH_REDIRECT_URI: http://127.0.0.1/api/auth/callback
187 NEXT_PUBLIC_SITE_URL: http://127.0.0.1
188 BARAZO_API_VERSION: edge
189 BARAZO_WEB_VERSION: edge
190
191 steps:
192 - name: Checkout barazo-deploy
193 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
194
195 - name: Login to Container Registry
196 uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
197 with:
198 registry: ${{ env.REGISTRY }}
199 username: ${{ github.actor }}
200 password: ${{ secrets.GITHUB_TOKEN }}
201
202 - name: Create smoke test fixtures
203 run: |
204 # Empty plugin manifest (no plugins needed for smoke test)
205 echo '[]' > plugins.json
206 # No-op plugin install script
207 printf '#!/bin/sh\nexit 0\n' > scripts/install-plugins.sh
208 chmod +x scripts/install-plugins.sh
209
210 - name: Start full stack
211 run: docker compose -f docker-compose.yml up -d
212
213 - name: Wait for services to be healthy
214 run: |
215 echo "Waiting for services to become healthy..."
216 TIMEOUT=120
217 ELAPSED=0
218 INTERVAL=5
219
220 while [ $ELAPSED -lt $TIMEOUT ]; do
221 HEALTHY=$(docker compose -f docker-compose.yml ps --format json 2>/dev/null \
222 | grep -c '"Health":"healthy"' || echo "0")
223
224 # postgres, valkey, barazo-api, barazo-web, caddy = 5 services with healthchecks
225 if [ "$HEALTHY" -ge 5 ]; then
226 echo "All services healthy after ${ELAPSED}s"
227 break
228 fi
229
230 echo " ${HEALTHY}/5 healthy (${ELAPSED}s elapsed)..."
231 sleep $INTERVAL
232 ELAPSED=$((ELAPSED + INTERVAL))
233 done
234
235 if [ $ELAPSED -ge $TIMEOUT ]; then
236 echo "::error::Timed out waiting for services to be healthy"
237 docker compose -f docker-compose.yml ps
238 docker compose -f docker-compose.yml logs --tail=50
239 exit 1
240 fi
241
242 - name: Run smoke tests
243 run: ./scripts/smoke-test.sh
244
245 - name: Dump logs on failure
246 if: failure()
247 run: |
248 echo "=== Service Status ==="
249 docker compose -f docker-compose.yml ps
250 echo ""
251 echo "=== Service Logs ==="
252 docker compose -f docker-compose.yml logs --tail=100
253
254 - name: Tear down
255 if: always()
256 run: docker compose -f docker-compose.yml down -v
257
258 # ======================================================================
259 # Job 3: Deploy to staging VPS (only if smoke test passes)
260 # ======================================================================
261 deploy:
262 name: Deploy to Staging
263 needs: [build, smoke-test]
264 runs-on: ubuntu-latest
265 permissions:
266 contents: read
267 timeout-minutes: 10
268
269 steps:
270 - name: Sync configuration files to VPS
271 uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1
272 with:
273 host: staging.barazo.forum
274 username: deploy
275 key: ${{ secrets.STAGING_SSH_KEY }}
276 script: |
277 set -e
278 cd ${{ env.DEPLOY_PATH }}
279 echo "Syncing configuration files from repo (commit ${{ github.sha }})..."
280 BASE="https://raw.githubusercontent.com/singi-labs/barazo-deploy/${{ github.sha }}"
281 for f in docker-compose.yml docker-compose.staging.yml Caddyfile .env.example; do
282 wget -qO "$f.tmp" "$BASE/$f"
283 mv "$f.tmp" "$f"
284 echo " Updated $f"
285 done
286 echo "Configuration files synced"
287
288 - name: Save current image digests (for rollback)
289 uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1
290 id: save-digests
291 with:
292 host: staging.barazo.forum
293 username: deploy
294 key: ${{ secrets.STAGING_SSH_KEY }}
295 script: |
296 cd ${{ env.DEPLOY_PATH }}
297 echo "Saving current digests for rollback..."
298 docker inspect --format='{{index .RepoDigests 0}}' $(docker compose ${{ env.COMPOSE_FILES }} ps -q barazo-api 2>/dev/null) 2>/dev/null > /tmp/rollback-api-digest || echo "no-previous-api"
299 docker inspect --format='{{index .RepoDigests 0}}' $(docker compose ${{ env.COMPOSE_FILES }} ps -q barazo-web 2>/dev/null) 2>/dev/null > /tmp/rollback-web-digest || echo "no-previous-web"
300 echo "API digest: $(cat /tmp/rollback-api-digest)"
301 echo "Web digest: $(cat /tmp/rollback-web-digest)"
302
303 - name: Check for new required env vars
304 uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1
305 with:
306 host: staging.barazo.forum
307 username: deploy
308 key: ${{ secrets.STAGING_SSH_KEY }}
309 script: |
310 cd ${{ env.DEPLOY_PATH }}
311 echo "Checking for new required env vars..."
312 EXAMPLE_VARS=$(grep -v '^#' .env.example | grep -v '^\s*$' | grep '=' | cut -d= -f1 | sort)
313 CURRENT_VARS=$(grep -v '^#' .env | grep -v '^\s*$' | grep '=' | cut -d= -f1 | sort)
314 MISSING=$(comm -23 <(echo "$EXAMPLE_VARS") <(echo "$CURRENT_VARS"))
315 if [ -n "$MISSING" ]; then
316 echo "::warning::New env vars in .env.example not in .env:"
317 echo "$MISSING"
318 echo ""
319 echo "Deploy continues -- these may be optional. Check .env.example for details."
320 else
321 echo "All .env.example vars present in .env"
322 fi
323
324 - name: Pull new images and deploy
325 id: deploy
326 uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1
327 with:
328 host: staging.barazo.forum
329 username: deploy
330 key: ${{ secrets.STAGING_SSH_KEY }}
331 script: |
332 set -e
333 cd ${{ env.DEPLOY_PATH }}
334
335 echo "Pulling latest images..."
336 docker compose ${{ env.COMPOSE_FILES }} pull barazo-api barazo-web
337
338 echo "Starting containers..."
339 docker compose ${{ env.COMPOSE_FILES }} up -d
340
341 echo "Pruning unused images..."
342 docker image prune -f
343
344 echo "Deploy complete at $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
345
346 # ------------------------------------------------------------------
347 # Post-deploy health checks
348 # ------------------------------------------------------------------
349 - name: Wait for containers to start
350 run: sleep 15
351
352 - name: Check container logs for crash patterns
353 id: log-check
354 uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1
355 with:
356 host: staging.barazo.forum
357 username: deploy
358 key: ${{ secrets.STAGING_SSH_KEY }}
359 script: |
360 cd ${{ env.DEPLOY_PATH }}
361 echo "Checking logs for crash patterns (last 30s)..."
362
363 LOGS=$(docker compose ${{ env.COMPOSE_FILES }} logs --since 30s barazo-api barazo-web 2>&1)
364 echo "$LOGS"
365
366 CRASH_PATTERNS="FATAL|ECONNREFUSED|missing env|ERR_MODULE_NOT_FOUND|Cannot find module|segfault|OOMKilled"
367 if echo "$LOGS" | grep -iE "$CRASH_PATTERNS"; then
368 echo ""
369 echo "CRASH_DETECTED=true"
370 echo "::error::Crash pattern detected in container logs"
371 exit 1
372 fi
373
374 # Check for containers that exited
375 EXITED=$(docker compose ${{ env.COMPOSE_FILES }} ps --format json 2>/dev/null | grep -i '"exited"' || true)
376 if [ -n "$EXITED" ]; then
377 echo "::error::One or more containers exited unexpectedly"
378 echo "$EXITED"
379 exit 1
380 fi
381
382 echo "No crash patterns detected"
383
384 - name: Verify health endpoints
385 id: health-check
386 uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1
387 with:
388 host: staging.barazo.forum
389 username: deploy
390 key: ${{ secrets.STAGING_SSH_KEY }}
391 script: |
392 cd ${{ env.DEPLOY_PATH }}
393 echo "Checking health endpoints..."
394
395 # Wait up to 90s for API to become healthy (Docker healthcheck: start-period 30s + interval 30s)
396 # Ports are not exposed to host, so we check Docker's own healthcheck status
397 for i in $(seq 1 18); do
398 API_HEALTH=$(docker inspect --format='{{.State.Health.Status}}' $(docker compose ${{ env.COMPOSE_FILES }} ps -q barazo-api 2>/dev/null) 2>/dev/null || echo "missing")
399 if [ "$API_HEALTH" = "healthy" ]; then
400 echo "API health: OK (healthy)"
401 break
402 fi
403 echo "API health: $API_HEALTH (attempt $i/18)"
404 sleep 5
405 done
406
407 if [ "$API_HEALTH" != "healthy" ]; then
408 echo "::error::API health check failed after 90s (status: $API_HEALTH)"
409 # Show recent logs for debugging
410 docker compose ${{ env.COMPOSE_FILES }} logs --tail=20 barazo-api 2>&1 || true
411 exit 1
412 fi
413
414 # Check web health
415 for i in $(seq 1 12); do
416 WEB_HEALTH=$(docker inspect --format='{{.State.Health.Status}}' $(docker compose ${{ env.COMPOSE_FILES }} ps -q barazo-web 2>/dev/null) 2>/dev/null || echo "missing")
417 if [ "$WEB_HEALTH" = "healthy" ]; then
418 echo "Web health: OK (healthy)"
419 break
420 fi
421 echo "Web health: $WEB_HEALTH (attempt $i/12)"
422 sleep 5
423 done
424
425 if [ "$WEB_HEALTH" != "healthy" ]; then
426 echo "::error::Web health check failed after 60s (status: $WEB_HEALTH)"
427 docker compose ${{ env.COMPOSE_FILES }} logs --tail=20 barazo-web 2>&1 || true
428 exit 1
429 fi
430
431 echo "All health checks passed"
432
433 - name: Verify login endpoint
434 id: login-check
435 uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1
436 with:
437 host: staging.barazo.forum
438 username: deploy
439 key: ${{ secrets.STAGING_SSH_KEY }}
440 script: |
441 set -e
442 cd ${{ env.DEPLOY_PATH }}
443 echo "Verifying login endpoint returns valid redirect URL..."
444
445 # Retry up to 6 times (30s total) -- API may still be starting after deploy
446 # Use 127.0.0.1 (not localhost) -- Alpine may not resolve localhost to IPv4
447 # Use a real Bluesky handle -- the API resolves the DID via the AT Protocol
448 for i in $(seq 1 6); do
449 RESPONSE=$(docker compose ${{ env.COMPOSE_FILES }} exec -T barazo-api \
450 wget -qO- "http://127.0.0.1:3000/api/auth/login?handle=bsky.app" 2>&1 || true)
451
452 if echo "$RESPONSE" | grep -q '"url"'; then
453 echo "Login endpoint OK: response contains redirect URL (attempt $i/6)"
454 exit 0
455 fi
456 echo "Login check attempt $i/6 failed, retrying in 5s..."
457 sleep 5
458 done
459
460 echo "::warning::Login endpoint did not return expected {url} field after 6 attempts"
461 echo "Response: $RESPONSE"
462 echo "This may indicate an OAuth configuration issue"
463
464 # ------------------------------------------------------------------
465 # Rollback on failure
466 # ------------------------------------------------------------------
467 - name: Rollback on failure
468 if: failure() && (steps.deploy.outcome == 'failure' || steps.log-check.outcome == 'failure' || steps.health-check.outcome == 'failure')
469 uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1
470 with:
471 host: staging.barazo.forum
472 username: deploy
473 key: ${{ secrets.STAGING_SSH_KEY }}
474 script: |
475 cd ${{ env.DEPLOY_PATH }}
476 echo "::warning::Rolling back to previous images..."
477
478 API_DIGEST=$(cat /tmp/rollback-api-digest 2>/dev/null || echo "")
479 WEB_DIGEST=$(cat /tmp/rollback-web-digest 2>/dev/null || echo "")
480
481 if [ -n "$API_DIGEST" ] && [ "$API_DIGEST" != "no-previous-api" ]; then
482 echo "Rolling back API to: $API_DIGEST"
483 docker pull "$API_DIGEST"
484 docker tag "$API_DIGEST" ghcr.io/singi-labs/barazo-api:edge
485 fi
486
487 if [ -n "$WEB_DIGEST" ] && [ "$WEB_DIGEST" != "no-previous-web" ]; then
488 echo "Rolling back Web to: $WEB_DIGEST"
489 docker pull "$WEB_DIGEST"
490 docker tag "$WEB_DIGEST" ghcr.io/singi-labs/barazo-web:edge
491 fi
492
493 docker compose ${{ env.COMPOSE_FILES }} up -d
494 echo "Rollback complete. Waiting for health..."
495 sleep 15
496
497 API_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/api/health 2>/dev/null || echo "000")
498 echo "Post-rollback API health: $API_STATUS"
499
500 - name: Deploy summary
501 if: success()
502 run: |
503 echo "## Staging Deploy Complete" >> "$GITHUB_STEP_SUMMARY"
504 echo "" >> "$GITHUB_STEP_SUMMARY"
505 echo "- **Trigger:** ${{ needs.build.outputs.trigger }}" >> "$GITHUB_STEP_SUMMARY"
506 echo "- **API ref:** ${{ needs.build.outputs.api_ref }}" >> "$GITHUB_STEP_SUMMARY"
507 echo "- **Web ref:** ${{ needs.build.outputs.web_ref }}" >> "$GITHUB_STEP_SUMMARY"
508 echo "- **Run:** #${{ github.run_number }}" >> "$GITHUB_STEP_SUMMARY"
509 echo "- **URL:** https://staging.barazo.forum" >> "$GITHUB_STEP_SUMMARY"