DEV Community

MACROGEN
MACROGEN

Posted on

A Practical CI/CD Pipeline: Build Once, Tag by Commit, Deploy Anywhere

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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" });
  }
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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

Top comments (1)

Collapse
 
abhinavs1920 profile image
Abhinav Sharma •

A good pipeline being boring is the right goal. Build-once plus SHA tagging turns rollback into a lookup instead of a rebuild, and the health check after deploy is the step most minimal pipelines quietly skip.