Infrastructure

GitHub Actions CI/CD for self-hosted infrastructure.


GitHub Actions is the CI/CD layer in most of the projects 47Network Studio delivers. Not because it's the most powerful option โ€” Jenkins, GitLab CI, and Tekton are all more flexible โ€” but because it's where the code already lives, the YAML is readable enough to be maintained by people who aren't CI specialists, and the ecosystem of community actions covers 90% of what teams need. This post covers the patterns that hold up in production: self-hosted runners with proper isolation, Vault secrets injection instead of GitHub secrets for sensitive values, deployment gates with environment protection rules, and rollback paths that work under pressure.

Self-hosted runners: when and why

GitHub-hosted runners are fine for open-source work and most SaaS development. For self-hosted infrastructure there are three reasons to bring your own runners:

  • Network access. Deploying to an internal Kubernetes cluster, pushing to a private registry, or running integration tests against databases that aren't internet-accessible all require a runner that's already inside your network.
  • Secrets proximity. A runner inside your network can pull secrets from Vault directly, without routing them through GitHub's secret storage.
  • Cost at scale. If you're running hundreds of workflow minutes per day, self-hosted is significantly cheaper than GitHub-hosted compute.
# Deploy a GitHub Actions runner on a dedicated Linux host
# Create a non-root user for isolation
useradd -m -s /bin/bash github-runner

# Download and configure
su - github-runner
mkdir actions-runner && cd actions-runner
curl -sL https://github.com/actions/runner/releases/download/v2.322.0/actions-runner-linux-x64-2.322.0.tar.gz | tar xz
./config.sh --url https://github.com/your-org/your-repo \
            --token YOUR_REGISTRATION_TOKEN \
            --labels self-hosted,linux,production \
            --name prod-runner-01 \
            --runnergroup default \
            --work /home/github-runner/_work

# Install as systemd service
sudo ./svc.sh install github-runner
sudo ./svc.sh start

Runner isolation matters. Self-hosted runners that share a host with production services are a security risk โ€” a compromised workflow can access host network, files, and credentials. Run runners in isolated VMs or containers, not on production hosts. For high-sensitivity deployments, use ephemeral runners (new VM per job, destroyed after) via tools like actions-runner-controller on Kubernetes.

Vault secrets injection: replacing GitHub secrets

GitHub's built-in secrets are fine for non-sensitive tokens (NPM publish keys, Slack notifications), but for production database credentials, API keys, and TLS certificates, you want secrets managed in Vault where you have audit logging, automatic rotation, and revocation capability. The hashicorp/vault-action community action handles this:

name: Deploy to production

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: [self-hosted, linux, production]
    environment: production   # Triggers protection rules
    
    steps:
      - uses: actions/checkout@v4

      # Authenticate to Vault using AppRole
      - name: Import secrets from Vault
        uses: hashicorp/vault-action@v3
        with:
          url: https://vault.internal.example.com
          method: approle
          roleId: ${{ secrets.VAULT_ROLE_ID }}       # Non-sensitive, can be a GitHub secret
          secretId: ${{ secrets.VAULT_SECRET_ID }}   # Wrapped token, short-lived
          secrets: |
            secret/data/production/db    username | DB_USER ;
            secret/data/production/db    password | DB_PASSWORD ;
            secret/data/production/api   key      | API_KEY ;

      - name: Deploy
        run: |
          # Secrets are now available as environment variables
          # They are masked in logs automatically
          ./deploy.sh --db-user "$DB_USER" --db-pass "$DB_PASSWORD"

Environment protection rules: the deployment gate

GitHub Environments let you require human approval before a workflow can deploy to production, and restrict which branches can trigger a production deployment. This is the most important safeguard in any deployment pipeline:

# .github/workflows/deploy.yml โ€” environment gates
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm test

  deploy-staging:
    needs: test
    runs-on: [self-hosted, linux, staging]
    environment: staging      # Deploys automatically after tests pass
    steps:
      - run: ./deploy.sh staging

  integration-tests:
    needs: deploy-staging
    runs-on: ubuntu-latest
    steps:
      - run: npm run test:integration -- --env staging

  deploy-production:
    needs: integration-tests
    runs-on: [self-hosted, linux, production]
    environment: production   # Requires manual approval + branch protection
    # In GitHub UI: Settings > Environments > production
    # Set: Required reviewers (minimum 1), Deployment branches (main only)
    steps:
      - run: ./deploy.sh production

Reusable workflows: DRY across repositories

If you're managing infrastructure for multiple services, the same deployment, testing, and release patterns repeat across repos. Reusable workflows let you define the pattern once in a central repo and call it from each service:

# In .github/workflows/deploy-service.yml (central repo: your-org/.github)
on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
      service-name:
        required: true
        type: string
    secrets:
      VAULT_ROLE_ID:
        required: true
      VAULT_SECRET_ID:
        required: true

jobs:
  deploy:
    runs-on: [self-hosted, linux, "${{ inputs.environment }}"]
    environment: "${{ inputs.environment }}"
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/vault-action@v3
        with:
          url: https://vault.internal.example.com
          method: approle
          roleId: ${{ secrets.VAULT_ROLE_ID }}
          secretId: ${{ secrets.VAULT_SECRET_ID }}
          secrets: "secret/data/${{ inputs.environment }}/${{ inputs.service-name }} * | SERVICE_"
      - run: ./deploy.sh "${{ inputs.environment }}"

# In each service repo: .github/workflows/deploy.yml
jobs:
  deploy:
    uses: your-org/.github/.github/workflows/deploy-service.yml@main
    with:
      environment: production
      service-name: payments-api
    secrets:
      VAULT_ROLE_ID: ${{ secrets.VAULT_ROLE_ID }}
      VAULT_SECRET_ID: ${{ secrets.VAULT_SECRET_ID }}

Rollback strategy: deploy artifacts, not git refs

The safest deployment pattern builds a versioned artifact (Docker image, tarball, Helm chart), tags it with the git SHA, and deploys that artifact to each environment. Rollback means deploying a previous artifact version โ€” no rebuild, no surprises:

name: Build and deploy

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}
    steps:
      - uses: actions/checkout@v4
      
      - name: Docker meta
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: registry.internal.example.com/payments-api
          tags: |
            type=sha,prefix=,suffix=,format=short   # Always tag with git SHA
            type=semver,pattern={{version}}           # Tag releases with version

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=registry,ref=registry.internal.example.com/payments-api:cache
          cache-to:   type=registry,ref=registry.internal.example.com/payments-api:cache,mode=max

  deploy:
    needs: build
    environment: production
    steps:
      - name: Deploy specific image tag
        run: |
          kubectl set image deployment/payments-api \
            app=registry.internal.example.com/payments-api:${{ github.sha }}
          kubectl rollout status deployment/payments-api --timeout=5m

      # Rollback if status check fails (kubectl rollout status returns non-zero)
      - name: Rollback on failure
        if: failure()
        run: kubectl rollout undo deployment/payments-api

In 47Network Studio engagements we pair GitHub Actions with self-hosted runners inside the client's network, Vault for all deployment secrets, and a mandatory staging โ†’ integration-tests โ†’ manual-approval โ†’ production gate. Every deployment to production creates a changelog entry with the git SHA, deploying team member, and Vault audit reference โ€” giving compliance teams a complete trail without any extra effort from developers.


โ† Back to Blog Makefile Guide โ†’