name: Deploy Docker to VPS on: workflow_run: workflows: ['Build and Push Docker Images'] types: - completed branches: - main push: branches: - main paths: - 'infra/**' - '.github/workflows/deploy-docker.yml' - '.github/workflows/docker-build-push.yml' workflow_dispatch: # Prevent multiple deployments from running simultaneously concurrency: group: deploy-vps cancel-in-progress: false permissions: contents: read packages: read jobs: deploy: runs-on: ubuntu-latest timeout-minutes: 30 if: github.event_name == 'workflow_dispatch' || github.event_name == 'push' || github.event.workflow_run.conclusion == 'success' steps: - name: Checkout repository uses: actions/checkout@v6 with: fetch-depth: 1 submodules: false - name: Deploy to VPS env: SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} VPS_HOST: ${{ secrets.VPS_HOST }} VPS_USER: ${{ secrets.VPS_USER }} VPS_TARGET_DIR: ${{ secrets.VPS_TARGET_DIR }} ENV_FILE_PRODUCTION: ${{ secrets.ENV_FILE_PRODUCTION }} GHCR_USERNAME: ${{ github.actor }} GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail echo "Deploy event: ${{ github.event_name }}" echo "Deploy ref: ${{ github.ref }}" echo "Deploy sha: ${{ github.sha }}" if [ -z "${SSH_PRIVATE_KEY:-}" ] || [ -z "${VPS_HOST:-}" ] || [ -z "${VPS_USER:-}" ] || [ -z "${VPS_TARGET_DIR:-}" ]; then echo "โŒ Deployment secrets are not fully configured. Please set SSH_PRIVATE_KEY, VPS_HOST, VPS_USER, and VPS_TARGET_DIR." exit 1 fi mkdir -p ~/.ssh echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa ssh-keyscan -H -t ed25519,rsa "$VPS_HOST" >> ~/.ssh/known_hosts # Use SSH multiplexing for faster subsequent commands SSH_OPTS=(-o ControlMaster=auto -o ControlPath=/tmp/ssh-%r@%h:%p -o ControlPersist=600 -o StrictHostKeyChecking=yes) ssh "${SSH_OPTS[@]}" "$VPS_USER@$VPS_HOST" "mkdir -p $VPS_TARGET_DIR && mkdir -p $VPS_TARGET_DIR/infra/compose" echo "$ENV_FILE_PRODUCTION" > .env.prod scp "${SSH_OPTS[@]}" .env.prod "$VPS_USER@$VPS_HOST:$VPS_TARGET_DIR/.env" echo "๐Ÿ” Logging in to GitHub Container Registry..." printf '%s' "$GHCR_TOKEN" | ssh "${SSH_OPTS[@]}" "$VPS_USER@$VPS_HOST" "docker login ghcr.io -u '$GHCR_USERNAME' --password-stdin" ssh "${SSH_OPTS[@]}" "$VPS_USER@$VPS_HOST" "export VPS_TARGET_DIR=$VPS_TARGET_DIR; bash -s" <<'EOF' set -euo pipefail cd "$VPS_TARGET_DIR" # Ensure shared network exists docker network inspect app-shared-net >/dev/null 2>&1 || docker network create app-shared-net echo "๐Ÿ”„ Synchronizing repository..." if [ ! -d ".git" ]; then echo "Initializing git repository..." git init git remote add origin https://github.com/asepharyana/asepharyana-hub.git fi git fetch origin main --depth=1 || true # Detect changed files before resetting ALL_COMPOSE_FILES="infra/compose/traefik.yml infra/compose/shared.yml infra/compose/scraper.yml infra/compose/nats.yml infra/compose/dapr.yml" TRAEFIK_DYNAMIC_DIR="infra/traefik/dynamic" if git rev-parse HEAD >/dev/null 2>&1; then BEFORE_REV=$(git rev-parse HEAD) git reset --hard FETCH_HEAD AFTER_REV=$(git rev-parse HEAD) if [ "$BEFORE_REV" = "$AFTER_REV" ]; then echo "โ„น๏ธ No new commits detected. Using full file list for safety." TARGET_COMPOSE="" else CHANGED=$(git diff --name-only "$BEFORE_REV" "$AFTER_REV" || true) echo "๐Ÿ“„ Changed files:" echo "$CHANGED" # Detect compose stack changes CHANGED_COMPOSE=$(echo "$CHANGED" | grep '^infra/compose/.*\.yml$' || true) TARGET_COMPOSE="" for f in $CHANGED_COMPOSE; do case " $ALL_COMPOSE_FILES " in *" $f "*) TARGET_COMPOSE="$TARGET_COMPOSE $f" ;; esac done TARGET_COMPOSE=$(printf '%s' "$TARGET_COMPOSE" | xargs || true) if [ -n "$TARGET_COMPOSE" ]; then echo "๐ŸŽฏ Detected compose stack changes in: $TARGET_COMPOSE" else echo "โ„น๏ธ No stack compose files changed." fi # Detect Traefik dynamic config changes CHANGED_TRAEFIK=$(echo "$CHANGED" | grep "^$TRAEFIK_DYNAMIC_DIR/" || true) if [ -n "$CHANGED_TRAEFIK" ]; then echo "๐ŸŽฏ Detected Traefik dynamic config changes:" echo "$CHANGED_TRAEFIK" RELOAD_TRAEFIK="true" else echo "โ„น๏ธ No Traefik dynamic config changes." fi # Detect infra file changes (Dockerfiles, config, traefik static) CHANGED_INFRA=$(echo "$CHANGED" | grep '^infra/' | grep -v '^infra/compose/' || true) if [ -n "$CHANGED_INFRA" ]; then echo "๐Ÿ“ฆ Detected other infra file changes:" echo "$CHANGED_INFRA" fi fi else git reset --hard FETCH_HEAD TARGET_COMPOSE="" fi if command -v "docker" >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then COMPOSE_CMD="docker compose" elif command -v docker-compose >/dev/null 2>&1; then COMPOSE_CMD="docker-compose" else echo "โŒ docker compose is not installed on the remote host." exit 1 fi # Construct compose arguments if [ -n "$TARGET_COMPOSE" ]; then COMPOSE_ARGS="" for f in $TARGET_COMPOSE; do if [ -f "$f" ]; then COMPOSE_ARGS="$COMPOSE_ARGS -f $f" fi done UP_FLAGS="-d" # No --remove-orphans for selective updates to avoid killing other services else echo "๐Ÿš€ Performing full deployment of all services..." COMPOSE_ARGS="" for f in $ALL_COMPOSE_FILES; do COMPOSE_ARGS="$COMPOSE_ARGS -f $f" done UP_FLAGS="-d --remove-orphans" fi echo "๐Ÿ“ฅ Pulling images for target services..." export DOCKER_CLI_EXPERIMENTAL=enabled PULL_SUCCESS=false # Retry pull up to 3 times to handle transient Docker attestation lease errors for attempt in 1 2 3; do echo "Pull attempt $attempt/3..." if $COMPOSE_CMD $COMPOSE_ARGS --env-file .env pull; then echo "โœ… Pull succeeded on attempt $attempt" PULL_SUCCESS=true break else echo "โš ๏ธ Pull attempt $attempt failed. Retrying in 5s..." sleep 5 fi done if [ "$PULL_SUCCESS" != "true" ]; then echo "โŒ Failed to pull images after 3 attempts." exit 1 fi echo "๐Ÿงน Clearing Git locks..." rm -f .git/shallow.lock || true echo "๐Ÿงน Removing stale target containers by container_name..." # Extract all explicitly defined container_names from compose files and remove them to prevent conflicts if [ -n "$TARGET_COMPOSE" ]; then for f in $TARGET_COMPOSE; do if [ -f "$f" ]; then grep "container_name:" "$f" | awk '{print $2}' | while read -r cname; do docker rm -f "$cname" >/dev/null 2>&1 || true done fi done else for f in $ALL_COMPOSE_FILES; do if [ -f "$f" ]; then grep "container_name:" "$f" | awk '{print $2}' | while read -r cname; do docker rm -f "$cname" >/dev/null 2>&1 || true done fi done fi echo "๐Ÿ†™ Starting services..." echo "๐Ÿ” Debug: Current docker containers:" docker ps -a $COMPOSE_CMD $COMPOSE_ARGS --env-file .env up $UP_FLAGS # โ”€โ”€ Traefik reload โ”€โ”€ if [ "${RELOAD_TRAEFIK:-false}" = "true" ]; then echo "๐Ÿ”„ Traefik dynamic config changed โ€” reloading Traefik..." # Traefik watches the dynamic config dir (providers.file.watch=true), # but send SIGHUP as insurance docker kill --signal HUP traefik 2>/dev/null || docker exec traefik kill -HUP 1 2>/dev/null || true echo "โœ… Traefik reload signal sent" fi EOF