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.