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 ciis more reproducible thannpm installin CI.- Dependency caching via
setup-nodespeeds 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 likeDATABASE_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, deploytype=shaso every deploy is immutable and traceable. Then your compose can takeIMAGE_TAGfrom 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
mainbuild 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