ci: proper Agiliton deployment with health checks, rollback, smoke tests
All checks were successful
Deploy Internet for Kids / Build & Push (push) Successful in 33s
Deploy Internet for Kids / Deploy (push) Successful in 6s
Deploy Internet for Kids / Health Check (push) Successful in 2s
Deploy Internet for Kids / Smoke Tests (push) Successful in 3s
Deploy Internet for Kids / Rollback (push) Has been skipped
Deploy Internet for Kids / Audit (push) Successful in 2s

- docker-compose.yml in repo (replaces inline generation in CI)
- /health nginx endpoint for container health checks
- HEALTHCHECK directive in Dockerfile
- CI pipeline: build → deploy → health check → smoke test → rollback on failure
- Smoke tests verify /health, /en/, /de/ after deploy
- Automatic rollback to previous image on health/smoke failure
- workflow_dispatch with force_deploy option
- Deploy-guard audit logging

IFK-6

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-04-02 21:00:34 +03:00
parent 8da917618a
commit ff9f6c1b7f
4 changed files with 227 additions and 29 deletions

View File

@@ -1,50 +1,223 @@
name: Build & Deploy # Internet for Kids - Production Deployment
# Deploys to infra VM at /opt/apps/internetforkids
#
# Features:
# - Docker registry-based deployment (build → push → pull)
# - Health check with automatic rollback on failure
# - Smoke tests against public URLs
# - Deploy-guard audit logging
name: Deploy Internet for Kids
on: on:
push: push:
branches: [main] branches: [main]
paths-ignore: ['**.md'] paths-ignore:
- '**.md'
- 'docs/**'
workflow_dispatch:
inputs:
force_deploy:
description: 'Force deploy even if health checks fail'
required: false
default: 'false'
env: env:
PROJECT_NAME: "internetforkids"
REGISTRY: gitea.agiliton.internal:3000 REGISTRY: gitea.agiliton.internal:3000
IMAGE: gitea.agiliton.internal:3000/christian/internetforkids IMAGE: gitea.agiliton.internal:3000/christian/internetforkids
TARGET_VM: infra.agiliton.internal REMOTE_HOST: infra.agiliton.internal
REMOTE_USER: root
DEPLOY_PATH: /opt/apps/internetforkids DEPLOY_PATH: /opt/apps/internetforkids
HEALTH_ENDPOINT: "http://localhost:3006/health"
PUBLIC_URL: "https://internetforkids.ong"
HEALTH_TIMEOUT: 60
jobs: jobs:
build-and-deploy: build-and-push:
name: Build & Push
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
submodules: true submodules: true
- name: Login to registry
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u christian --password-stdin
- name: Build and push
run: |
DOCKER_BUILDKIT=1 docker build --pull \
-t ${{ env.IMAGE }}:${{ github.sha }} \
-t ${{ env.IMAGE }}:latest \
.
docker push ${{ env.IMAGE }}:${{ github.sha }}
docker push ${{ env.IMAGE }}:latest
- name: Cleanup
if: always()
run: docker builder prune -f --filter "until=24h" 2>/dev/null || true
deploy:
name: Deploy
runs-on: ubuntu-latest
needs: build-and-push
outputs:
previous_image: ${{ steps.deploy.outputs.previous_image }}
steps:
- uses: actions/checkout@v4
- name: Setup SSH - name: Setup SSH
run: | run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H ${{ env.TARGET_VM }} >> ~/.ssh/known_hosts 2>/dev/null || true ssh-keyscan -H ${{ env.REMOTE_HOST }} >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Login & Build & Push
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u christian --password-stdin
DOCKER_BUILDKIT=1 docker build --pull -t ${{ env.IMAGE }}:latest .
docker push ${{ env.IMAGE }}:latest
- name: Deploy - name: Deploy
id: deploy
run: | run: |
ssh root@${{ env.TARGET_VM }} << 'EOF' # Capture current image digest for rollback
mkdir -p /opt/apps/internetforkids PREVIOUS=$(ssh ${{ env.REMOTE_USER }}@${{ env.REMOTE_HOST }} \
cd /opt/apps/internetforkids "docker inspect --format='{{.Image}}' internetforkids 2>/dev/null || echo 'none'")
cat > docker-compose.yml << 'COMPOSE' echo "previous_image=$PREVIOUS" >> $GITHUB_OUTPUT
services: echo "Previous image: $PREVIOUS"
internetforkids:
image: gitea.agiliton.internal:3000/christian/internetforkids:latest # Sync docker-compose.yml and deploy
container_name: internetforkids scp docker-compose.yml ${{ env.REMOTE_USER }}@${{ env.REMOTE_HOST }}:${{ env.DEPLOY_PATH }}/docker-compose.yml
restart: unless-stopped
ports: ssh ${{ env.REMOTE_USER }}@${{ env.REMOTE_HOST }} << 'EOF'
- "3006:80" cd /opt/apps/internetforkids
labels: docker pull gitea.agiliton.internal:3000/christian/internetforkids:latest
- "com.centurylinklabs.watchtower.enable=true" docker compose up -d --force-recreate --remove-orphans
COMPOSE
docker pull gitea.agiliton.internal:3000/christian/internetforkids:latest
docker compose up -d --force-recreate --remove-orphans
EOF EOF
- name: Cleanup
if: always() echo "Deployment initiated"
run: docker builder prune -f --filter "until=24h" 2>/dev/null || true
health-check:
name: Health Check
runs-on: ubuntu-latest
needs: deploy
steps:
- name: Setup SSH
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H ${{ env.REMOTE_HOST }} >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Wait for health
run: |
echo "Waiting for health (timeout: ${HEALTH_TIMEOUT}s)..."
ELAPSED=0
while [ $ELAPSED -lt $HEALTH_TIMEOUT ]; do
if ssh ${{ env.REMOTE_USER }}@${{ env.REMOTE_HOST }} \
"curl -sf ${{ env.HEALTH_ENDPOINT }}" >/dev/null 2>&1; then
echo "Health check passed!"
exit 0
fi
echo "Waiting... ($ELAPSED/${HEALTH_TIMEOUT}s)"
sleep 10
ELAPSED=$((ELAPSED + 10))
done
echo "::error::Health check timeout after ${HEALTH_TIMEOUT}s"
ssh ${{ env.REMOTE_USER }}@${{ env.REMOTE_HOST }} \
"cd ${{ env.DEPLOY_PATH }} && docker compose ps && docker compose logs --tail 20" || true
if [ "${{ github.event.inputs.force_deploy }}" = "true" ]; then
echo "::warning::Force deploy — continuing despite health failure"
exit 0
fi
exit 1
smoke-test:
name: Smoke Tests
runs-on: ubuntu-latest
needs: health-check
steps:
- name: Setup SSH
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H ${{ env.REMOTE_HOST }} >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Test key URLs
run: |
FAILED=0
for path in /health /en/ /de/; do
STATUS=$(ssh ${{ env.REMOTE_USER }}@${{ env.REMOTE_HOST }} \
"curl -so /dev/null -w '%{http_code}' http://localhost:3006${path}" 2>/dev/null || echo "000")
if [ "$STATUS" = "200" ] || [ "$STATUS" = "302" ]; then
echo "OK $path → $STATUS"
else
echo "FAIL $path → $STATUS"
FAILED=$((FAILED + 1))
fi
done
if [ $FAILED -gt 0 ]; then
echo "::error::$FAILED smoke test(s) failed"
exit 1
fi
echo "All smoke tests passed"
rollback:
name: Rollback
runs-on: ubuntu-latest
needs: [deploy, health-check, smoke-test]
if: failure() && needs.deploy.outputs.previous_image != 'none' && needs.deploy.outputs.previous_image != ''
steps:
- name: Setup SSH
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H ${{ env.REMOTE_HOST }} >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Rollback to previous image
run: |
PREVIOUS="${{ needs.deploy.outputs.previous_image }}"
echo "::warning::Rolling back to image $PREVIOUS"
ssh ${{ env.REMOTE_USER }}@${{ env.REMOTE_HOST }} << EOF
cd ${{ env.DEPLOY_PATH }}
docker tag $PREVIOUS ${{ env.IMAGE }}:latest
docker compose up -d --force-recreate
EOF
- name: Verify rollback
run: |
sleep 10
if ssh ${{ env.REMOTE_USER }}@${{ env.REMOTE_HOST }} \
"curl -sf ${{ env.HEALTH_ENDPOINT }}" >/dev/null 2>&1; then
echo "Rollback successful"
else
echo "::error::Rollback may have failed — check manually"
fi
audit:
name: Audit
runs-on: ubuntu-latest
needs: [deploy, health-check, smoke-test, rollback]
if: always()
steps:
- name: Setup SSH
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H ${{ env.REMOTE_HOST }} >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Log deployment
run: |
STATUS="success"
[ "${{ needs.health-check.result }}" = "failure" ] && STATUS="failed"
[ "${{ needs.smoke-test.result }}" = "failure" ] && STATUS="failed"
[ "${{ needs.rollback.result }}" = "success" ] && STATUS="rolled_back"
echo "${{ env.PROJECT_NAME }} deployment: $STATUS (sha: ${{ github.sha }})"
ssh ${{ env.REMOTE_USER }}@${{ env.REMOTE_HOST }} \
"/opt/scripts/deploy-guard.sh check 'ci-deploy ${{ env.PROJECT_NAME }} $STATUS'" 2>/dev/null || true

View File

@@ -4,6 +4,9 @@ WORKDIR /src
RUN hugo --minify --destination /output RUN hugo --minify --destination /output
FROM nginx:alpine FROM nginx:alpine
RUN apk add --no-cache curl
COPY --from=builder /output /usr/share/nginx/html COPY --from=builder /output /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost:80/health || exit 1

15
docker-compose.yml Normal file
View File

@@ -0,0 +1,15 @@
services:
internetforkids:
image: gitea.agiliton.internal:3000/christian/internetforkids:latest
container_name: internetforkids
restart: unless-stopped
ports:
- "3006:80"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
labels:
- "com.centurylinklabs.watchtower.enable=true"

View File

@@ -27,6 +27,13 @@ server {
add_header Cache-Control "public, must-revalidate"; add_header Cache-Control "public, must-revalidate";
} }
# Health check endpoint
location /health {
access_log off;
return 200 "ok";
add_header Content-Type text/plain;
}
# Redirect unprefixed paths to /en/ # Redirect unprefixed paths to /en/
location ~ ^/(?!en/|de/|css/|js/|img/|favicon|android|apple|site|llms) { location ~ ^/(?!en/|de/|css/|js/|img/|favicon|android|apple|site|llms) {
return 302 /en$request_uri; return 302 /en$request_uri;