Skip to content

Deploy

Deploy #260

Workflow file for this run

name: Deploy
on:
workflow_run:
workflows: [CI]
branches: [main]
types: [completed]
# Only ever one deploy in flight; a newer push supersedes an older one still queued.
concurrency:
group: deploy-production
cancel-in-progress: false
# No checkout and no GitHub API call here — everything happens over SSH on
# the VPS with its own secrets, so GITHUB_TOKEN needs no scope at all.
permissions: {}
jobs:
deploy:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- name: Deploy over SSH
env:
DEPLOY_SHA: ${{ github.event.workflow_run.head_sha }}
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
envs: DEPLOY_SHA
script: |
set -e
cd ${{ secrets.VPS_DEPLOY_PATH }}
git fetch origin main
# Deploy the exact commit CI validated, not whatever HEAD of main
# happens to be by the time this job runs — main could have moved
# again in between (e.g. a second push landing before this job starts).
git reset --hard "$DEPLOY_SHA"
# Which override files to combine (prod, observability, portainer,
# glitchtip, ...) is NOT hardcoded here — it comes from COMPOSE_FILE
# in the VPS's own .env (Compose reads that automatically). Adding a
# new optional add-on later means editing .env on the VPS, not this
# workflow. See .env.example / README "Self-hosting" for the format.
#
# IMAGE_TAG pins docker-compose.yml's api/web `image:` to the
# exact images the CI docker-push job built and pushed to GHCR
# for this SHA (see .github/workflows/ci.yml), instead of the
# floating `latest` tag — same "exact commit CI validated"
# guarantee as the git reset above. Also feeds the Homepage
# "Version" widget, which reads GIT_SHA baked into the api image.
export IMAGE_TAG=$(git rev-parse --short HEAD)
docker compose pull
# --remove-orphans cleans up containers for services dropped from
# COMPOSE_FILE (e.g. an add-on removed from the VPS .env). It does
# NOT address "port is already allocated" on a service that's
# still part of the config — that's usually a stale container
# from an interrupted previous deploy not yet releasing its port,
# a transient docker daemon race, so retry a few times before
# giving up.
for attempt in 1 2 3; do
if docker compose up -d --remove-orphans; then
break
fi
if [ "$attempt" = 3 ]; then
echo "docker compose up -d failed after 3 attempts" >&2
exit 1
fi
echo "docker compose up -d failed (attempt $attempt/3), retrying in 5s..."
sleep 5
done