A good CI/CD pipeline is boring. Every change goes through the same steps, deploys look the same every time, and rolling back takes seconds.

This post builds a minimal pipeline for a containerised app using GitHub Actions. The principles also apply to other CI systems and cloud providers.

Principles first

  1. Build once. Create a single artefact (a container image) and promote that same artefact through staging and production. Don't rebuild per environment, or you're not testing what you ship.
  2. Make artefacts immutable. Tag images with the commit SHA. Avoid deploying latest, because you can't tell what it is or roll back to it reliably.
  3. Keep config out of the image. Environment variables and secrets come from the environment at runtime.
  4. Automate the check after deploy. A deploy isn't done until a health check passes.

Step 1: Test and build on every push

# .github/workflows/ci.yml name: ci on: push: branches: [main] pull_request: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci - run: npm run lint --if-present - run: npm test build: needs: test if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max 

Pull requests run tests only. Merges to main also build and push an image tagged with the exact commit.

Step 2: A small, secure Dockerfile

FROM node:20-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM node:20-alpine WORKDIR /app ENV NODE_ENV=production COPY --from=build /app/dist ./dist COPY package*.json ./ RUN npm ci --omit=dev USER node EXPOSE 3000 CMD ["node", "dist/server.js"] 

Multi-stage builds keep the final image small, and USER node avoids running as root.

Step 3: Deploy the exact image

How you deploy depends on your platform (ECS, Kubernetes, Cloud Run, a VM with Docker, and so on). The pattern is the same: tell the platform to run the image tagged with this commit SHA.

 deploy-staging: needs: build runs-on: ubuntu-latest environment: staging steps: - name: Deploy image run: | echo "Deploying ghcr.io/${{ github.repository }}:${{ github.sha }} to staging" # replace with your platform's command, for example: # kubectl set image deployment/app app=ghcr.io/${{ github.repository }}:${{ github.sha }} - name: Wait for health check run: | for i in {1..20}; do if curl -fsS https://staging.example.com/healthz; then exit 0; fi sleep 5 done echo "Health check failed" && exit 1 deploy-production: needs: deploy-staging runs-on: ubuntu-latest environment: production # add required reviewers in repo settings steps: - name: Deploy the same image run: echo "Deploying the same SHA to production" 

Using GitHub Environments lets you add required approvals for production and keep secrets separate per environment.

Step 4: Add a real health endpoint

app.get("/healthz", async (req, res) => { try { await db.query("SELECT 1"); // check critical dependencies res.status(200).json({ status: "ok" }); } catch { res.status(503).json({ status: "unhealthy" }); } }); 

Keep it fast and cheap. Platforms use it to decide whether to send traffic to a new version, and your pipeline uses it to decide whether a deploy succeeded.

Step 5: Rollback is just another deploy

Because every image is tagged with a commit SHA, rolling back means deploying the previous known-good SHA. No rebuild required.

# example: redeploy the previous version kubectl set image deployment/app app=ghcr.io/org/app:<previous-sha> 

Practice this before you need it. A rollback you've never tried isn't a plan.

Beyond the basics

  • Secrets: prefer short-lived credentials via OIDC (for example, GitHub Actions to AWS, Azure or GCP) over long-lived keys stored in secrets.
  • Infrastructure as code: manage cloud resources with Terraform or similar, reviewed through pull requests.
  • Progressive delivery: canary or blue/green releases reduce the impact of a bad deploy.
  • Observability: logs, metrics and alerts tied to each release, so you can see when a deploy changed behaviour.
  • Database migrations: make them backward-compatible so old and new versions can run side by side during a rollout.

Checklist

  • [ ] One build, promoted across environments
  • [ ] Images tagged by commit SHA, never latest
  • [ ] Config and secrets injected at runtime
  • [ ] Health check gates every deploy
  • [ ] Manual approval for production
  • [ ] Rollback tested

What does your deployment pipeline look like, and what's the one thing you'd change?

Further reading: Cloud DevOps Solutions