A reliable GitHub Actions CI/CD pipeline should make every change easier to test, review, deploy, and recover. This guide presents maintainable workflow examples for application tests, Docker image builds, cloud deployments, Terraform changes, environment approvals, and secure authentication across AWS, Azure, and Google Cloud.
Overview
GitHub Actions can coordinate the delivery process without forcing every repository into the same release model. The most maintainable approach is to separate the pipeline into clear stages, give each stage one responsibility, and make promotion between environments explicit.
A practical CI/CD pipeline often follows this sequence:
- Validate the change with formatting checks, static analysis, and unit tests.
- Build a deployable artifact, such as a compiled package or container image.
- Scan and test the artifact before it is promoted.
- Deploy automatically to a non-production environment.
- Run smoke tests or obtain an approval before production deployment.
- Record the release identifier and provide a rollback path.
The exact cloud command changes between providers, but the handoffs remain similar. A test job produces confidence, a build job produces an immutable artifact, and a deployment job moves that artifact through controlled environments. Keeping these concerns distinct makes failures easier to diagnose and allows teams to change one part without rewriting the entire workflow.
For containerized services, the image tag should identify the source revision or another unique build identifier rather than relying only on a mutable tag such as latest. For infrastructure, the pipeline should separate planning from applying changes. Teams managing shared Terraform state can also review the guidance in Terraform State Management: Remote Backends, Locking, and Safe Team Workflows.
Step-by-step workflow
1. Start with pull-request validation
Run fast checks for pull requests before a change can be merged. Keep this workflow focused on feedback rather than deployment. A simple example looks like this:
name: Pull request checks
on:
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out source
uses: actions/checkout@v4
- name: Set up runtime
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: npm
- name: Install dependencies
run: npm ci
- name: Check formatting
run: npm run format:check
- name: Run linting
run: npm run lint
- name: Run tests
run: npm test -- --ci
Replace the runtime and commands with the equivalents for the project. The important design choices are reproducible dependency installation, a read-only default permission, and checks that finish quickly enough to support normal code review. If integration tests need a database or queue, declare those services explicitly and keep their setup in the workflow or a reusable test script.
2. Build once and publish an immutable container
For a Docker-based service, build the image after validation and publish it to the registry used by the target cloud platform. The deployment should later refer to the exact image produced here.
name: Build container
on:
push:
branches: [main]
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/example-org/example-app:${{ github.sha }}
The registry login action and image destination can be replaced with the provider-specific registry integration for AWS, Azure, or Google Cloud. Keep cloud authentication separate from the build logic when possible. That separation makes the Docker workflow easier to test and reduces the number of jobs that need access to deployment credentials.
3. Deploy to a non-production environment
Create a deployment job that consumes the image tag or artifact generated by the build. In a Kubernetes workflow, this may mean updating a manifest, Helm values file, or deployment configuration. The deployment job should then wait for the platform to report a successful rollout.
deploy-staging:
needs: build
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Configure cloud access
run: ./scripts/configure-cloud-access.sh
- name: Deploy image
env:
IMAGE_TAG: ${{ github.sha }}
run: ./scripts/deploy-staging.sh "$IMAGE_TAG"
- name: Run smoke tests
run: ./scripts/smoke-test-staging.sh
Use the cloud provider's supported GitHub authentication mechanism and prefer short-lived, workload-based credentials over long-lived static keys. The setup script should be auditable and should not print tokens or sensitive configuration. For Kubernetes-specific readiness checks, see the Kubernetes Deployment Checklist: From Container Image to Production Readiness.
4. Protect production with an environment gate
Production should be a separate environment rather than another command in the staging job. GitHub environment protection rules can be used to require review or other repository-configured controls before the job proceeds.
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Configure production cloud access
run: ./scripts/configure-cloud-access.sh production
- name: Deploy the tested image
env:
IMAGE_TAG: ${{ github.sha }}
run: ./scripts/deploy-production.sh "$IMAGE_TAG"
- name: Verify rollout
run: ./scripts/verify-production.sh
The production job should promote the same artifact tested in staging, not rebuild it. A failed verification should leave a clear record and link to the rollback procedure. Approval is most useful when reviewers can see the change summary, test result, artifact identifier, migration risk, and recovery plan.
5. Add Terraform plan and apply as separate jobs
Infrastructure changes need a distinct workflow path. On a pull request, run formatting, validation, and a plan. Store the plan output as a reviewable artifact when the team’s process requires it. After merge, apply only from a protected environment or controlled branch.
name: Terraform
on:
pull_request:
paths: ['infra/**']
push:
branches: [main]
paths: ['infra/**']
permissions:
contents: read
jobs:
plan:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform -chdir=infra fmt -check
- run: terraform -chdir=infra init -input=false
- run: terraform -chdir=infra validate
- run: terraform -chdir=infra plan -input=false
apply:
if: github.event_name == 'push'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: ./scripts/configure-cloud-access.sh production
- run: terraform -chdir=infra init -input=false
- run: terraform -chdir=infra apply -auto-approve -input=false
Adjust the state backend, locking, provider authentication, and approval model to match the team’s infrastructure process. Do not put sensitive Terraform values into logs or unreviewed pull-request comments. The related Terraform CI/CD Tutorial covers the broader deployment workflow.
Tools and handoffs
A pipeline is a chain of handoffs, so define the contract between jobs before adding more actions. A test job should report pass or fail. A build job should publish a named artifact. A deployment job should consume that artifact and report the environment, revision, and result.
- Source control: Use pull requests, required checks, and a documented merge strategy.
- Build tools: Use language-native package managers and Docker BuildKit-compatible actions where containers are involved.
- Cloud authentication: Use federated or short-lived access where supported, with narrowly scoped permissions.
- Environments: Keep staging and production configuration distinct, and protect production independently.
- Infrastructure: Keep Terraform plans, state, locking, and applies under a deliberate team workflow.
- Operations: Connect deployment output to logs, alerts, and rollback documentation.
Secrets should be limited to values that cannot be replaced by workload identity, repository variables, or environment configuration. Review permissions at the workflow and job level, and avoid granting write access to jobs that only run tests. For a focused security review, read How to Secure GitHub Actions Secrets and Runners and Cloud IAM Best Practices: A Practical Guide to Least-Privilege Access.
Quality checks
Before calling a GitHub Actions workflow production-ready, verify the process rather than only the YAML syntax:
- A pull request cannot merge while required tests or security checks are failing.
- The same built artifact is promoted through environments without an unnecessary rebuild.
- Production access is limited to the jobs and environments that need it.
- Logs identify the commit, artifact, target environment, and deployment result without exposing secrets.
- Smoke tests verify the service from the perspective of a user or dependent system.
- Rollback steps are documented and have been exercised in a suitable environment.
- Terraform plans are reviewed, state is stored safely, and concurrent changes are controlled.
- Actions, runner images, dependencies, and cloud permissions are periodically reviewed.
Keep workflows small enough to understand. If several repositories use the same sequence, extract the stable portion into a reusable workflow, but leave application-specific commands visible at the calling repository. Abstraction should remove duplication without hiding deployment decisions from reviewers.
When to revisit
Review the pipeline whenever the tools or process change. That includes a move to a different container registry, a new cloud account or subscription, a change in runner strategy, an update to deployment targets, or a new requirement for approvals and auditability. Revisit it after a failed release as well: the goal is to convert the failure into a clearer check, safer permission boundary, or more reliable rollback step.
At a regular maintenance interval, inspect action versions, runtime versions, dependency caching, runner availability, secret usage, and cloud IAM permissions. Also compare the workflow with the service’s current architecture. A pipeline designed for a single container may no longer be appropriate after adding background workers, database migrations, scheduled jobs, or multiple regions.
To put this guide into practice, start with pull-request tests, add an immutable build artifact, deploy that artifact to staging, and then place production behind a protected environment. Once the basic path is reliable, add Terraform plan and apply controls, smoke tests, observability links, and a tested rollback procedure. This staged approach keeps automation useful while giving the team a clear process to improve as the application and cloud platform evolve.