Build artifacts, Dockerfiles, multi-stage builds, GHCR — build a Docker image in CI, push it to a registry tagged with the commit SHA, and scan dependencies for vulnerabilities. Code becomes a deployable, versioned unit.
The output of your build stage — a deployable, versioned unit of software. Once built, an artifact is immutable. You deploy the same artifact to staging and production.
| Docker image | Containerised app — most common today |
| npm package | JavaScript library published to npm registry |
| JAR / WAR | Java application |
| Binary | Compiled Go / Rust / C++ executable |
| Helm chart | Kubernetes deployment package |
| dist/ folder | Built front-end static files |
| MAJOR | Breaking changes. Existing code may break. | 2.0.0 → 3.0.0 |
| MINOR | New features, backward-compatible. | 2.3.0 → 2.4.0 |
| PATCH | Bug fixes, backward-compatible. | 2.4.0 → 2.4.1 |
myapp:$\{{ github.sha }}myapp:v2.4.1 and myapp:latest.
# === Every line creates a new LAYER === FROM node:20-alpine # Base image. alpine = tiny (5 MB) LABEL maintainer="devops@example.com" LABEL version="1.0.0" WORKDIR /app # All subsequent commands run here # COPY package*.json first (separate layer) # Why: this layer is cached unless package.json changes # npm install won't re-run if only src/ files changed COPY package*.json ./ RUN npm ci --only=production # prod deps only — no jest, eslint # COPY source AFTER install (cache optimisation) COPY src/ ./src/ # Security: never run as root in production! RUN addgroup -S appgroup && \ adduser -S appuser -G appgroup USER appuser EXPOSE 3000 # Documents the port (doesn't publish) HEALTHCHECK --interval=30s --timeout=3s \ CMD wget -qO- http://localhost:3000/health || exit 1 CMD ["node", "src/index.js"] # Start command (exec form)
FROM — base image. Always use specific tags (node:20-alpine not node:latest)WORKDIR — sets working directory for all following commandsCOPY src dest — copy files from host into imageRUN cmd — execute during build, creates a layerENV KEY=val — set environment variableEXPOSE port — documents which port the app usesUSER — switch to non-root userHEALTHCHECK — Docker checks if container is healthyCMD ["node", "app.js"] — default start command (exec form)USER appuser before CMD.
# ═══ STAGE 1: Build ══════════════════════════ # Has: node, npm, all devDependencies, build tools FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci # ALL deps including devDeps COPY src/ ./src/ RUN npm run build # transpile/bundle → dist/ # ═══ STAGE 2: Runtime ════════════════════════ # Has: ONLY what's needed to run in production # No jest, eslint, typescript, webpack, etc. FROM node:20-alpine AS runtime WORKDIR /app # Security: non-root user RUN addgroup -S appgroup && \ adduser -S appuser -G appgroup # Only copy what we need from builder COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/dist ./dist USER appuser EXPOSE 3000 CMD ["node", "dist/index.js"] # === Size comparison === # Single stage (node:20): ~1.1 GB # Single stage (node:20-alpine): ~170 MB # Multi-stage (node:20-alpine): ~80 MB # Multi-stage (distroless): ~50 MB
# Build the image docker build -t my-devops-app:local . # Check the image size docker images my-devops-app # Run it locally docker run -p 3000:3000 my-devops-app:local # Test the app curl http://localhost:3000 # Check running containers docker ps # Stop it docker stop $(docker ps -q)
node_modules
.git
coverage/
*.md
.env
.github/name: Docker Build & Push on: push: branches: [ main ] # Push image on every merge push: tags: [ 'v*.*.*' ] # Also push on git tags env: REGISTRY: ghcr.io IMAGE_NAME: \${{ github.repository }} # owner/repo jobs: build-push: runs-on: ubuntu-latest permissions: contents: read packages: write # ← needed to push to GHCR steps: - uses: actions/checkout@v4 # Login to GitHub Container Registry - name: Login to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: \${{ github.actor }} password: \${{ secrets.GITHUB_TOKEN }} # Extract metadata for tags - name: Extract metadata id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/\${{ env.IMAGE_NAME }} tags: | type=sha # sha-abc1234 type=ref,event=branch # main type=semver,pattern={{version}} # v2.4.1 # Build and push - name: Build and push uses: docker/build-push-action@v5 with: context: . push: true tags: \${{ steps.meta.outputs.tags }} labels: \${{ steps.meta.outputs.labels }} cache-from: type=gha # GitHub Actions cache cache-to: type=gha,mode=max
| Feature | GHCR | Docker Hub |
|---|---|---|
| Auth in CI | GITHUB_TOKEN (auto) | Need secret |
| Free private | ✅ Unlimited | 1 repo free |
| Rate limits | None (logged in) | 100/6h anon |
| Location | ghcr.io/owner/repo | docker.io/user/repo |
secrets.GITHUB_TOKEN is automatically available in every workflow run. You don't need to create it — GitHub provides it. For GHCR, just add permissions: packages: write to the job.
cache-from: type=gha uses GitHub Actions cache storage for Docker layers. First build: 2 min. Subsequent builds (no code change): 15 sec. Cache layer by layer.
# === Basic scan === npm audit # Output: # found 3 vulnerabilities (1 moderate, 2 high) # Run 'npm audit fix' to fix them # === Auto-fix safe updates === npm audit fix # === Fix including breaking changes === npm audit fix --force # careful — may break things # === JSON output for CI parsing === npm audit --json | jq '.metadata.vulnerabilities' # === Fail CI if HIGH or CRITICAL vulns === npm audit --audit-level=high # exits 1 if any HIGH/CRITICAL found → CI fails # === List all outdated packages === npm outdated # === Check specific package === npm audit --json | jq '.vulnerabilities | keys[]'
- name: Security audit run: npm audit --audit-level=high # Fails if HIGH or CRITICAL vulnerabilities found - name: Scan Docker image uses: aquasecurity/trivy-action@master with: image-ref: ghcr.io/\${{ env.IMAGE_NAME }}:latest format: table exit-code: '1' # Fail if vulns found severity: HIGH,CRITICAL
npm audit --audit-level=high in CI..github/dependabot.yml.version: 2 updates: - package-ecosystem: "npm" directory: "/" schedule: interval: "weekly" open-pull-requests-limit: 5 - package-ecosystem: "docker" directory: "/" schedule: interval: "weekly" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "monthly"
Write a Dockerfile → build locally → push to GHCR via CI → verify image at ghcr.io
/health endpoint. This is what gets containerised..dockerignore to exclude node_modules and .git.docker build -t my-devops-app:local . → docker run -p 3000:3000 my-devops-app:local → curl localhost:3000/health → 200 OK.docker/build-push-action. Tags: SHA + latest.ghcr.io/username/my-devops-app. ✅npm audit --audit-level=high step before Docker build. If you have high/critical vulnerabilities, fix them first.const http = require('http'); const PORT = process.env.PORT || 3000; const server = http.createServer((req, res) => { if (req.url === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', version: '1.0.0' })); } else { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('Hello from my-devops-app!\n'); } }); server.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); module.exports = server; // for testing
# Stage 1: Build (has devDeps + build tools) FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY src/ ./src/ # Stage 2: Runtime (lean, secure) FROM node:20-alpine AS runtime WORKDIR /app RUN addgroup -S appgroup && \ adduser -S appuser -G appgroup COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/src ./src USER appuser EXPOSE 3000 HEALTHCHECK CMD wget -qO- http://localhost:3000/health CMD ["node", "src/index.js"]
node_modules .git coverage *.md .env .github tests
name: Docker Build & Push on: push: branches: [ main ] env: REGISTRY: ghcr.io IMAGE_NAME: \${{ github.repository }} jobs: build-push: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - name: Setup Node + audit uses: actions/setup-node@v4 with: { node-version: '20', cache: 'npm' } - run: npm ci - run: npm audit --audit-level=high - name: Login to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: \${{ github.actor }} password: \${{ secrets.GITHUB_TOKEN }} - name: Extract metadata id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/\${{ env.IMAGE_NAME }} tags: | type=sha type=raw,value=latest - name: Build and 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
3 questions · 5 minutes · SemVer, multi-stage builds, npm audit
2.4.1, what does the middle number 4 represent?npm testnpm checknpm auditnpm scanghcr.io/username/my-devops-app ✓npm audit passing (no high/critical) ✓ci: add docker build and push to ghcr ✓# === Base === FROM node:20-alpine # Base image (ALWAYS use specific tag) FROM scratch # Empty base (for static binaries) FROM node:20 AS builder # Named stage for multi-stage # === Metadata === LABEL key="value" # Key-value metadata ARG VERSION=1.0 # Build-time variable (not in image) ENV NODE_ENV=production # Runtime environment variable # === Filesystem === WORKDIR /app # Set working directory COPY src/ ./src/ # Copy from host to image COPY --from=builder /app . # Copy from another stage ADD archive.tar.gz /app/ # Like COPY but auto-extracts # === Execution === RUN npm ci # Run during build (creates layer) RUN ["npm", "ci"] # Exec form (no shell) CMD ["node", "app.js"] # Default start command (overridable) ENTRYPOINT ["node"] # Fixed executable (CMD appends args) # === Network / Volumes === EXPOSE 3000 # Document port (doesn't publish) VOLUME ["/data"] # Declare persistent data mount point # === Security === USER appuser # Switch to non-root user HEALTHCHECK --interval=30s \ CMD wget -qO- localhost:3000/health # === Build === docker build -t name:tag . docker build --target builder . # Build specific stage only docker build --no-cache . # Ignore layer cache
CMD ["node", "app.js"] — default command, easily overridden:
docker run myapp node other.js ← replaces CMD
ENTRYPOINT ["node"] — fixed executable, CMD becomes args:
docker run myapp app.js ← runs node app.js
Best practice: use ENTRYPOINT for the executable + CMD for default args.
ARG — build-time only. Available during docker build, not in running container. Use for: version numbers, build timestamps.ENV — runtime. Available in the running container. Use for: NODE_ENV, PORT.ARG VERSION=1.0 ENV APP_VERSION=$VERSION # Set ENV from ARG
# Build docker build -t myapp:1.0.0 . docker build -t myapp:1.0.0 -f Dockerfile.prod . # List images docker images docker images myapp # filter by name # Tag docker tag myapp:1.0.0 ghcr.io/user/myapp:1.0.0 docker tag myapp:1.0.0 myapp:latest # Push / Pull docker push ghcr.io/user/myapp:1.0.0 docker pull ghcr.io/user/myapp:latest # Remove docker rmi myapp:1.0.0 docker image prune # remove dangling images # Inspect docker inspect myapp:1.0.0 docker history myapp:1.0.0 # show layers docker image ls --format "table {{.Repository}}\t{{.Size}}"
# Run docker run -p 3000:3000 myapp:1.0.0 docker run -d myapp:1.0.0 # detached (background) docker run --name mycon myapp:1.0.0 docker run -e NODE_ENV=prod myapp # env var docker run -v /host:/container myapp # volume mount # List containers docker ps # running docker ps -a # all (including stopped) # Interact docker exec -it mycon sh # shell in container docker logs mycon # view logs docker logs -f mycon # follow logs # Stop / remove docker stop mycon docker rm mycon docker stop $(docker ps -q) # stop all # Cleanup docker system prune # remove all unused docker system df # disk usage
# === Tag with multiple identifiers === # using docker/metadata-action@v5: tags: | type=sha # sha-abc1234 ← always unique type=raw,value=latest # latest ← always current type=ref,event=branch # main ← branch name type=semver,pattern={{version}} # v2.4.1 ← git tag type=semver,pattern={{major}}.{{minor}} # v2.4 # === Result for push to main: === # ghcr.io/user/myapp:sha-a3f7c2d ← unique, immutable # ghcr.io/user/myapp:main ← always latest on main # ghcr.io/user/myapp:latest ← latest of all builds # === Result for git tag v2.4.1: === # ghcr.io/user/myapp:v2.4.1 ← semantic version # ghcr.io/user/myapp:2.4 ← minor version # ghcr.io/user/myapp:latest ← latest # === Deploying: use SHA tag === # helm upgrade myapp chart/ \ # --set image.tag=sha-a3f7c2d # → Exact reproducible deployment, easy rollback
myapp:latest is mutable — it changes on every push. If you deploy :latest and something breaks, you can't roll back to "the previous latest".myapp:sha-a3f7c2d is immutable — always the same image. To roll back: deploy the previous SHA tag. Perfect audit trail.
# In GitHub Actions: on: push: tags: [ 'v*.*.*' ] # Trigger with: git tag v2.4.1 git push origin v2.4.1 # → Runs docker.yml → image tagged v2.4.1
| Day | Topic | Lab Output | Status |
|---|---|---|---|
| Day 11 | CI/CD Concepts | ci.yml live in Actions tab | ✅ |
| Day 12 | Pipeline Deep Dive | Multi-job matrix + Jenkins parallel | ✅ |
| Day 13 | Testing in CI | Jest + 70% coverage gate | ✅ |
| Day 14 ← TODAY | Artifacts & Docker | Docker image pushed to GHCR on merge | ✅ |
| Day 15 | Continuous Deployment | Staging auto + prod approval gate | Tomorrow |
my-devops-app/ ├── .github/workflows/ │ ├── ci.yml ← Day 11: basic CI │ ├── ci-advanced.yml ← Day 12: matrix │ ├── test.yml ← Day 13: Jest │ ├── docker.yml ← Day 14: GHCR ← new │ └── deploy.yml ← Day 15: CD ├── Dockerfile ← Day 14: multi-stage ├── .dockerignore ← Day 14 ├── src/ tests/ jest.config.js ← Day 13
node:20-alpine not node)USER appuser)--only=production in npm install (no devDeps)HEALTHCHECK for container orchestration.dockerignore — exclude node_modules, .git, testspackage*.json before COPY . . — cache npm install layernode:20-alpine vs node:20 = 5 MB vs 350 MB)&& to reduce layerscache-from: type=gha)npm ci --only=production in runtime stagenode:20 ≈ 350 MB ← never use in prod node:20-alpine ≈ 5 MB base Single stage ≈ 170 MB (with node_modules) Multi-stage alpine ≈ 80 MB ← good for most apps distroless ≈ 50 MB ← best security
FROM gcr.io/distroless/nodejs20-debian12 — no shell, no package manager, no OS utilities. Smallest attack surface possible. Used by Google, Kubernetes. Harder to debug but most secure.
| Problem | Cause | Fix |
|---|---|---|
| docker build fails "COPY failed" | File doesn't exist in build context | Check path in COPY instruction. Run from directory containing Dockerfile. Check .dockerignore isn't excluding needed files. |
| GHCR push: "denied" | Missing permissions: packages: write | Add permissions: packages: write to the job in your workflow. |
| Container exits immediately | App crashes on startup | docker logs container-name to see the error. |
| Large image size | node_modules in final stage, no .dockerignore | Use multi-stage build. Add .dockerignore. Use alpine base. docker history image-name shows which layers are large. |
| npm ci fails in Docker | package-lock.json not copied | Use COPY package*.json ./ (copies both package.json AND package-lock.json). |
| CI Docker build slow (no cache) | No cache configured | Add cache-from: type=gha and cache-to: type=gha,mode=max to build-push-action. |
| npm audit fails CI | High/critical vulnerability found | npm audit fix locally. Commit updated package-lock.json. If unfixable, use --audit-level=critical. |
| Port not accessible on localhost | Forgot -p flag | docker run -p 3000:3000 myapp — host:container port mapping required. |
FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY src/ ./src/ FROM node:20-alpine WORKDIR /app RUN addgroup -S app && adduser -S app -G app COPY --from=builder /app . USER app EXPOSE 3000 CMD ["node", "src/index.js"]
node_modules .git coverage *.md .env .github tests
name: Docker on: push: branches: [ main ] jobs: push: runs-on: ubuntu-latest permissions: packages: write contents: read steps: - uses: actions/checkout@v4 - uses: docker/login-action@v3 with: registry: ghcr.io username: \${{ github.actor }} password: \${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v5 with: context: . push: true tags: | ghcr.io/\${{ github.repository }}:\${{ github.sha }} ghcr.io/\${{ github.repository }}:latest cache-from: type=gha cache-to: type=gha,mode=max
ci: add docker multi-stage build and ghcr push