Skip to content
7 min read

One deploy.yml to Rule Them All: Branch-Aware CI/CD on Self-Hosted Runners

CI/CDGitHub ActionsGCPJava

Duplicated workflow files are a time bomb. Someone fixes Maven auth in deploy-dev.yml, forgets deploy-stage.yml, and three weeks later stage breaks "for no reason."

The consolidation pattern

One deploy.yml per repo. The branch decides the environment:

name: Deployon:  push:    branches: [dev, stage]jobs:  deploy-dev:    if: github.ref == 'refs/heads/dev'    runs-on: [self-hosted, dev-runner]    steps:      - uses: actions/checkout@v4      - name: Build        run: mvn -B clean package -DskipTests      - name: Deploy service        run: |          sudo cp target/*.jar /opt/ala-microservices/app.jar          sudo systemctl restart ala-service  deploy-stage:    if: github.ref == 'refs/heads/stage'    runs-on: [self-hosted, stage-runner]    steps:      - uses: actions/checkout@v4      - name: Build        run: mvn -B clean package -DskipTests      - name: Deploy service        run: |          sudo cp target/*.jar /opt/ala-microservices/app.jar          sudo systemctl restart ala-service

Same file, two jobs, branch conditions doing the routing. Merging dev into stage promotes both the code and any pipeline improvements - drift becomes structurally impossible.

Why self-hosted runners

For deploys onto private GCP VMs, self-hosted runners on (or beside) the target servers mean:

  • No SSH keys stored in GitHub secrets.
  • No inbound firewall holes - runners make outbound connections to GitHub.
  • Artifacts land directly where they run; no copy step across the internet.

The sharp edges

Three problems ate most of the migration time, so you can skip them:

  1. JAR naming. Maven's versioned artifact names break systemd units expecting a fixed path. Normalize at deploy time (cp target/*.jar app.jar), not in the unit file.
  2. Runner permissions. The runner user needs *scoped* sudo - exactly cp to one directory and systemctl restart on named units. A blanket NOPASSWD: ALL on a CI user is an incident waiting for a headline.
  3. Private Maven repos. Put credentials in a runner-local settings.xml, not in the workflow, so tokens never transit GitHub's log pipeline.

The payoff: five repos, one mental model, and deploys boring enough that nobody watches them anymore. Boring is the goal.