# Production deploy: build the image in CI, publish to GHCR, then SSH into the # Hetzner VPS to pull + restart. The VPS never builds — it only pulls the image # that passed CI. Rollback = re-run this workflow from an older commit, or # `docker compose pull` a previous sha-tag on the VPS. # # Secrets: VPS_SSH_KEY (dedicated deploy key), VPS_HOST, VPS_USER. name: VPS Deploy on: push: branches: [main] workflow_dispatch: concurrency: group: vps-deploy cancel-in-progress: false env: IMAGE: ghcr.io/${{ github.repository }} jobs: build-push: name: Build & push to GHCR runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/metadata-action@v5 id: meta with: images: ${{ env.IMAGE }} tags: | type=raw,value=latest type=sha,format=long - uses: docker/build-push-action@v6 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max deploy: name: Deploy to VPS needs: build-push runs-on: ubuntu-latest steps: - name: Install SSH key uses: webfactory/ssh-agent@v0.9.1 with: ssh-private-key: ${{ secrets.VPS_SSH_KEY }} - name: Trust VPS host key run: | mkdir -p ~/.ssh ssh-keyscan -H "${{ secrets.VPS_HOST }}" >> ~/.ssh/known_hosts - name: Pull image and restart app run: | ssh "${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}" bash -s <<'EOF' set -euo pipefail cd /root/projects/arbpulse git fetch origin main git checkout main git reset --hard origin/main cd deploy docker compose pull app docker compose up -d app echo "waiting for health..." for i in $(seq 1 12); do if curl -fsS http://127.0.0.1:8080/api/health >/dev/null 2>&1; then echo "app healthy" docker image prune -f >/dev/null exit 0 fi sleep 5 done echo "app did not become healthy in time" >&2 docker compose logs --tail 50 app >&2 exit 1 EOF