Docker Containerization & GitHub Actions CI/CD
Building multi-stage Docker images for Node.js APIs, orchestrating services with Compose, and deploying automatically with GitHub Actions on every push to main.
Overview
Containerization with Docker ensures your application runs identically in development, staging, and production. Paired with GitHub Actions, every push to main triggers automated testing, image building, and deployment — zero manual steps.
Multi-Stage Dockerfile (Node.js API)
Multi-stage builds keep the final image lean by discarding build tools:
# ── Stage 1: Dependencies ──────────────────────────────────────────
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
# ── Stage 2: Builder ──────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build # compile TypeScript
# ── Stage 3: Runner (final image) ─────────────────────────────────
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Copy only what we need from previous stages
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
# Non-root user for security
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
CMD ["node", "dist/main.js"]
Result: ~85 MB final image vs ~600 MB naive single-stage.
Docker Compose for Local Dev
# docker-compose.yml
version: "3.9"
services:
api:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://postgres:secret@db:5432/myapp
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
volumes:
- ./src:/app/src # hot-reload in dev override
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_PASSWORD: secret
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 5
cache:
image: redis:7-alpine
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
pgdata:
# Start everything
docker compose up -d
# Follow API logs
docker compose logs -f api
# Run migrations inside the container
docker compose exec api npm run db:migrate
GitHub Actions Pipeline
# .github/workflows/deploy.yml
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: test
POSTGRES_PASSWORD: secret
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-retries 5
redis:
image: redis:7-alpine
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test
env:
DATABASE_URL: postgresql://postgres:secret@localhost:5432/test
REDIS_URL: redis://localhost:6379
build-push:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- 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
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
type=raw,value=latest
- name: Build & push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build-push
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
docker pull ghcr.io/${{ github.repository }}:latest
docker compose -f /opt/app/docker-compose.prod.yml up -d --no-deps api
docker image prune -f
Health Check Endpoint
// health.controller.ts
import { Controller, Get } from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource } from "typeorm";
import { redis } from "@/lib/redis";
@Controller("health")
export class HealthController {
constructor(@InjectDataSource() private readonly db: DataSource) {}
@Get()
async check() {
const [dbOk, redisOk] = await Promise.allSettled([
this.db.query("SELECT 1"),
redis.ping(),
]);
const status = dbOk.status === "fulfilled" && redisOk.status === "fulfilled"
? "ok" : "degraded";
return {
status,
db: dbOk.status === "fulfilled" ? "up" : "down",
redis: redisOk.status === "fulfilled" ? "up" : "down",
uptime: process.uptime(),
};
}
}
.dockerignore
node_modules
dist
.env*
*.log
.git
coverage
Key Takeaways
- Multi-stage builds reduce production image size by 7–10×
- GitHub Actions layer caching (
type=gha) makes image builds 3–5× faster - Run real Postgres + Redis as service containers in CI — never mock them
- Always add a
/healthendpoint and wire it to your load balancer's health check - Deploy with
--no-depsin Compose to hot-swap a single service without downtime