CI/CD Pipelines with GitHub Actions: Test, Build, and Deploy a Dockerized Web App (Hands-On)

CI/CD Pipelines with GitHub Actions: Test, Build, and Deploy a Dockerized Web App (Hands-On)

If you’ve ever shipped a bug because you “forgot to run tests” or deployed the wrong build, a CI/CD pipeline is the fix. CI (Continuous Integration) runs your checks automatically on every push/PR. CD (Continuous Delivery/Deployment) packages and ships your app reliably.

In this hands-on guide, you’ll build a practical GitHub Actions pipeline for a Dockerized web app that:

  • Runs lint + unit tests on every pull request
  • Builds a production Docker image on merges to main
  • Pushes the image to GitHub Container Registry (GHCR)
  • Deploys to a server via SSH using docker compose
  • Performs a simple health check and keeps the process repeatable

This setup is intentionally “junior/mid-friendly”: a clear pipeline you can copy, adjust, and extend.

1) Example app shape (minimal but realistic)

Assume a repo like this (Node is just an example—same ideas apply to Python, PHP, etc.):

my-app/ api/ package.json src/ test/ Dockerfile docker-compose.prod.yml .github/ workflows/ ci.yml deploy.yml 

Your api/Dockerfile should build a production image. Here’s a common multi-stage approach:

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

For deployment, you’ll use a production compose file that references an image tag, not a local build:

# docker-compose.prod.yml services: api: image: ghcr.io/YOUR_ORG_OR_USER/my-app-api:latest restart: unless-stopped ports: - "3000:3000" environment: - NODE_ENV=production - DATABASE_URL=${DATABASE_URL} healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"] interval: 10s timeout: 3s retries: 10 

Make sure your app has a simple endpoint like GET /health returning 200.

2) CI workflow: run on every PR

Your CI workflow should be fast and deterministic. Run it on pull requests (and optionally pushes), and fail early when something breaks.

# .github/workflows/ci.yml name: CI on: pull_request: branches: ["main"] push: branches: ["main"] jobs: test: runs-on: ubuntu-latest defaults: run: working-directory: api steps: - name: Checkout uses: actions/checkout@v4 - name: Use Node.js uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" cache-dependency-path: api/package-lock.json - name: Install run: npm ci - name: Lint run: npm run lint - name: Unit tests run: npm test 

Notes you’ll appreciate later:

  • npm ci is more reproducible than npm install in CI.
  • Dependency caching via setup-node speeds up runs.
  • Keep CI “pure”: it should not require secret production config.

3) Build and push a Docker image on merges to main

Now create a separate workflow for building and pushing the Docker image. We’ll push to GHCR so your server can pull the image.

First, ensure your package/image name matches: ghcr.io/<owner>/<repo> or a custom name. You also need permissions for the workflow to write packages.

# .github/workflows/deploy.yml name: Build & Deploy on: push: branches: ["main"] permissions: contents: read packages: write env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }}-api jobs: build-and-push: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} tags: | type=raw,value=latest type=sha - name: Build and push uses: docker/build-push-action@v6 with: context: ./api file: ./api/Dockerfile push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} 

This publishes two tags:

  • latest (easy for servers)
  • sha-<commit> (great for rollbacks and traceability)

4) Deploy to a server with SSH + docker compose

There are fancier deployment approaches (Kubernetes, managed platforms), but SSH + Docker is still common and totally valid. The key is to make it repeatable.

On your server, you want a folder like /opt/my-app containing:

  • docker-compose.prod.yml
  • .env (server-only secrets like DATABASE_URL)

Create a dedicated deploy user, install Docker + Compose, and log into GHCR on the server once:

docker login ghcr.io -u YOUR_GH_USERNAME # Use a token with read:packages if needed 

Now add a deploy job that SSHes into the server, pulls the new image, restarts, and checks health. Add these GitHub secrets:

  • SSH_HOST, SSH_USER, SSH_PRIVATE_KEY
  • (Optional) SSH_PORT
# .github/workflows/deploy.yml (append this job below build-and-push) deploy: runs-on: ubuntu-latest needs: build-and-push steps: - name: Deploy over SSH uses: appleboy/[email protected] with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} port: ${{ secrets.SSH_PORT || 22 }} script: | set -e cd /opt/my-app echo "Pull latest images..." docker compose -f docker-compose.prod.yml pull echo "Restart services..." docker compose -f docker-compose.prod.yml up -d echo "Wait for health..." # Basic loop: check container health up to ~60s for i in $(seq 1 20); do STATUS=$(docker inspect --format='{{json .State.Health.Status}}' $(docker compose -f docker-compose.prod.yml ps -q api) | tr -d '"') echo "Health: $STATUS" if [ "$STATUS" = "healthy" ]; then echo "Deployment successful." exit 0 fi sleep 3 done echo "Service did not become healthy." docker compose -f docker-compose.prod.yml logs --tail=200 api exit 1 

This is already better than “SSH in and do random things” because:

  • It always pulls the latest published images.
  • It restarts with up -d, preserving the desired state.
  • It fails the workflow if the app doesn’t become healthy.

5) Practical hardening: make it safer and easier to debug

Here are small upgrades that pay off immediately:

  • Pin and deploy by commit SHA tag. Instead of latest, deploy type=sha so every deploy is immutable and traceable. Then your compose can take IMAGE_TAG from env.
  • Add rollback. Keep the previous SHA and switch back if health fails. Even a manual rollback is easier when you have SHA tags published.
  • Use environment protection rules. GitHub Environments can require approvals before deploy to production.
  • Separate CI vs CD concerns. CI for PR confidence; CD for controlled shipping.
  • Log artifacts. If your app produces build artifacts or test reports, upload them on failure using actions/upload-artifact.

Here’s the “deploy by SHA” idea in practice. Update your metadata tags to ensure SHA is always present (already done above), then set an env var in the deploy job:

# Example snippet: compute the image + sha tag env: IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.repository }}-api TAG: sha-${{ github.sha }} 

And on the server, update docker-compose.prod.yml to use ${IMAGE_TAG}:

services: api: image: ghcr.io/YOUR_ORG_OR_USER/my-app-api:${IMAGE_TAG} 

Then your SSH script can write IMAGE_TAG before running compose:

echo "IMAGE_TAG=sha-${GITHUB_SHA}" > .deploy.env docker compose -f docker-compose.prod.yml --env-file .deploy.env pull docker compose -f docker-compose.prod.yml --env-file .deploy.env up -d 

6) What you achieved (and what to do next)

You now have a working CI/CD pipeline that’s simple, reliable, and maintainable:

  • PRs run lint + tests automatically
  • Merges to main build a Docker image and push to GHCR
  • The server pulls and restarts via docker compose
  • A health check prevents “silent broken deploys”

Next upgrades (pick one): add database migrations as a controlled step, deploy a staging environment on every PR (preview), or add notifications (Slack/Teams) when deploys succeed/fail.


Leave a Reply

Your email address will not be published. Required fields are marked *