Skip to content

Continuous deployment

For CD on a completely locked server we use a Github Actions self-hosted runner. It does not require any incoming ports to be open — the runner grabs tasks via long polling.

How it works

push to stg/main
            GitHub Actions
        long polling (no open ports required)
      Self-hosted runner (staging/production server)
            ├─ 1. Pre-deploy DB backup
      ├─ 2. git pull + docker compose build + up
      ├─ 3. Health check loop  GET /ht/
      └─ 4. Clean old backups (retain 7)

Initial server setup

1. Create GitHub environments

In your repo navigate to Settings → Environments and create two environments: staging and production.

2. Add a self-hosted runner

Navigate to Settings → Actions → Runners → New self-hosted runner and follow the instructions. When prompted for labels during setup, add the matching environment label (staging or production).

# Switch to appuser and navigate to home
su - appuser
cd /home/appuser

# Follow the runner download + configure instructions shown by GitHub
# When asked for labels, enter:
staging    # for the staging server
# or
production # for the production server

3. Install the runner as a service

# Switch to root
sudo su

# Navigate to the runner folder
cd /home/appuser/actions-runner

# Install and start the service running as appuser
sudo ./svc.sh install appuser
sudo ./svc.sh start

# Verify it's running
sudo ./svc.sh status

Deployment workflow

The workflow file lives at .github/workflows/deploy-staging.yml. It runs automatically on every push to stg.

name: Deploy to Staging

on:
  push:
    branches:
      - stg

jobs:
  deploy:
    runs-on:
      - self-hosted
      - staging
    environment: staging
    env:
      APP_DIR: /home/appuser/${{ github.event.repository.name }}

    steps:
      - name: Start deployment
        run: echo "🚀 Starting deployment to staging..."

      - name: Pre-deploy database backup
        working-directory: ${{ env.APP_DIR }}
        run: task prod.db.backup

      - name: Deploy application
        working-directory: ${{ env.APP_DIR }}
        run: task prod.deploy

      - name: Wait for app to be healthy
        working-directory: ${{ env.APP_DIR }}
        run: task prod.health

      - name: Clean old backups
        working-directory: ${{ env.APP_DIR }}
        run: task prod.db.backup.clean

      - name: Deployment complete
        run: echo "✅ Staging deployment successful"

Taskfile tasks involved

Task When What it does
prod.db.backup Pre-deploy Dumps DB via greenmask before any changes
prod.deploy Deploy git pulldocker compose buildup -dprune
prod.health Post-deploy Polls $SITE_URL/ht/ every 5s, up to 2 minutes
prod.db.backup.clean Post-deploy Removes backups older than the 7 most recent

The prod.health task reads SITE_URL from your .env file automatically via Taskfile's dotenv directive.

Deployment flow per push

git push origin stg
            runner picks up the job
            ├─ greenmask dump  S3/storage
            ├─ git pull
      ├─ docker compose build
      ├─ docker compose up -d
      ├─ docker system prune -f
            ├─ GET /ht/ every 5s (max 2 min)
         ├─ HTTP 200  proceed
         └─ timeout  dump logs + fail
            └─ greenmask delete --retain-recent 7

Useful service management commands

# Check runner status
sudo /home/appuser/actions-runner/svc.sh status

# Stop / restart the runner
sudo /home/appuser/actions-runner/svc.sh stop
sudo /home/appuser/actions-runner/svc.sh start

# View runner logs
journalctl -u actions.runner.* -f