Build a CI/CD Pipeline for a Web App with GitHub Actions (Tests → Docker Build → Deploy)

Build a CI/CD Pipeline for a Web App with GitHub Actions (Tests → Docker Build → Deploy)

CI/CD (Continuous Integration / Continuous Delivery) sounds “enterprise-y,” but for most web teams it boils down to a simple goal: every push should be verified automatically, and every release should be repeatable. In this hands-on guide, you’ll set up a practical pipeline using GitHub Actions that:

  • runs your tests on every pull request
  • builds a Docker image and pushes it to a container registry
  • deploys the new version to a server over SSH using docker compose

This approach works for many stacks (Node, Python, PHP, etc.). You’ll see examples that are easy to adapt for your app.

What You’ll Build

We’ll create two workflows:

  • CI (on PRs and main pushes): install deps, run tests, lint, build.
  • CD (on tags/releases): build Docker image, push to registry, deploy.

Why split them? CI should run often and be fast. CD should run only when you mean “ship this.”

Repository Setup (Minimal)

Assume your repo has:

  • a web app with tests (npm test or pytest etc.)
  • a Dockerfile
  • a docker-compose.yml used on the server

Here’s a simple Dockerfile example for a Node app (adapt as needed):

# Dockerfile FROM node:20-alpine AS deps WORKDIR /app COPY package*.json ./ RUN npm ci FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production COPY --from=deps /app/node_modules ./node_modules COPY . . EXPOSE 3000 CMD ["npm", "start"]

And a deployment-focused docker-compose.yml that pulls the image from a registry:

# docker-compose.yml services: web: image: ghcr.io/your-org/your-repo:latest ports: - "80:3000" environment: - NODE_ENV=production restart: unless-stopped

Tip: Keep the server compose file simple and stable. The pipeline updates the image tag; the server just pulls and restarts.

CI Workflow: Tests on Pull Requests

Create .github/workflows/ci.yml. This example runs on PRs and pushes to main. It installs dependencies with caching, runs lint and tests, and does a “smoke” Docker build to catch Dockerfile issues early.

name: CI on: pull_request: push: branches: [ "main" ] jobs: test: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - name: Install dependencies run: npm ci - name: Lint run: npm run lint --if-present - name: Test run: npm test docker-build: runs-on: ubuntu-latest needs: test steps: - name: Checkout uses: actions/checkout@v4 - name: Build Docker image (smoke) run: docker build -t app-smoke:${{ github.sha }} .

If you’re using Python instead, swap the Node setup with Python (same concept):

- name: Setup Python uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install dependencies run: | python -m pip install -U pip pip install -r requirements.txt - name: Test run: pytest -q

CD Workflow: Build, Push, Deploy on Tags

Now the “ship it” part. We’ll trigger deployment when you push a tag like v1.2.3. This is a nice habit because tags form an audit trail and map cleanly to releases.

Create .github/workflows/cd.yml:

name: CD on: push: tags: - "v*.*.*" env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: build-and-push: runs-on: ubuntu-latest permissions: contents: read packages: write outputs: image_tag: ${{ steps.meta.outputs.version }} 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 }}/${{ env.IMAGE_NAME }} tags: | type=semver,pattern={{version}} type=raw,value=latest - name: Build and push uses: docker/build-push-action@v6 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} deploy: runs-on: ubuntu-latest needs: build-and-push steps: - name: Deploy over SSH uses: appleboy/[email protected] with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} script: | set -e cd /opt/myapp docker compose pull docker compose up -d docker image prune -f

This workflow:

  • builds your image and pushes it to GitHub Container Registry (GHCR)
  • SSH’es into the server, pulls the latest image, and restarts the service

Server Setup: One-Time Steps

On your server (Ubuntu example), install Docker and set up your app directory:

# create app directory sudo mkdir -p /opt/myapp sudo chown -R $USER:$USER /opt/myapp # put docker-compose.yml there cd /opt/myapp nano docker-compose.yml # first run (optional) docker compose up -d

Also make sure the server can pull from your registry. For GHCR with a private image, you can log in once using a PAT (or use a deploy token). For public images, you can skip this.

docker login ghcr.io -u YOUR_GH_USERNAME

Secrets You Must Add in GitHub

Go to Settings → Secrets and variables → Actions and add:

  • DEPLOY_HOST (server IP/hostname)
  • DEPLOY_USER (e.g., ubuntu)
  • DEPLOY_SSH_KEY (private key used for deploy)

Create a dedicated SSH key pair for CI deployments. On your machine:

ssh-keygen -t ed25519 -C "github-actions-deploy" -f ./deploy_key

Add the public key (deploy_key.pub) to ~/.ssh/authorized_keys on the server for the deploy user, and paste the private key (deploy_key) into DEPLOY_SSH_KEY in GitHub Secrets.

Make Deployments Safer: Pin to an Explicit Tag

Using latest is convenient, but you’ll eventually want “deploy exactly this version.” A small improvement is to deploy the SemVer tag. Update your server compose file to use an environment variable:

# docker-compose.yml services: web: image: ghcr.io/your-org/your-repo:${APP_VERSION} ports: - "80:3000" restart: unless-stopped

Then change your deploy script to write the version and redeploy:

script: | set -e cd /opt/myapp echo "APP_VERSION=${{ github.ref_name }}" > .env docker compose pull docker compose up -d

Now each release tag (like v1.2.3) becomes a reproducible deployment.

Debugging Tips When Things Fail

  • CI fails on tests: reproduce locally with the same commands used in the workflow (npm ci, npm test).
  • Docker build fails: run docker build . locally; check missing files in .dockerignore.
  • Deploy succeeds but app is broken: SSH to server and inspect logs: docker compose logs -f --tail=200
  • Wrong image deployed: if using latest, switch to explicit tags (previous section).

Next Steps: Add Quality Gates

Once the basics work, you can evolve the pipeline without rewriting it:

  • Add a type-check step (tsc, mypy).
  • Add security scanning (dependency audit, container scan).
  • Add preview environments for PRs (spin up ephemeral deployments).
  • Add database migrations as a controlled step (run migrations before restart).

The key idea: start with a pipeline that is boring and reliable. Your future self will thank you when releases become a button (or just a tag) instead of a ritual.


Leave a Reply

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