hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Deploying Node.js Apps with Docker

Why Docker

  • The "Works on My Machine" Problem
  • Docker Concepts

Your First Dockerfile

  • Writing a Dockerfile
  • Multi-Stage Builds
  • The .dockerignore File

Running Containers

  • Running and Managing Containers
  • Environment Variables and Secrets
  • Health Checks

Multi-Container Apps

  • Docker Compose
  • Adding a Reverse Proxy
  • Persistent Data

Deploying to Production

  • Deploying to a VPS
  • HTTPS with Let's Encrypt
  • Zero-Downtime Deploys

CI/CD

  • Building Images in CI
  • Automated Deployment

Production Hardening

  • Container Security
  • Logging and Monitoring
  • Deployment Checklist and Capstone

Automated Deployment

The full pipeline

Developer pushes to main
→ GitHub Actions runs tests
→ Tests pass → build Docker image
→ Push image to registry
→ Trigger deploy on the server
→ Server pulls new image
→ Zero-downtime swap
→ App is live

The developer pushes code. Everything else is automated.

Triggering the deploy

After CI pushes the image, the server needs to know to pull it. Two common approaches:

SSH from CI. The GitHub Actions workflow SSHes into the server and runs the deploy script.

Webhook. The server runs a small service that listens for webhook calls. CI hits the webhook, the server deploys.

SSH-based deploy

Add a deploy step to the GitHub Actions workflow:

deploy:
  needs: build
  runs-on: ubuntu-latest
  steps:
    - name: Deploy to server
      uses: appleboy/ssh-action@v1
      # This is a community GitHub Action that SSHes into a remote
      # server and runs commands. It handles the SSH connection,
      # authentication with the private key, and command execution.
      with:
        host: ${{ secrets.SERVER_HOST }}
        username: ${{ secrets.SERVER_USER }}
        key: ${{ secrets.SERVER_SSH_KEY }}
        script: |
          cd /app
          docker pull ${{ secrets.DOCKER_USERNAME }}/myapp:latest
          docker compose up -d --no-deps app
          echo "Deployed at $(date)"

Add three more secrets to your repository: SERVER_HOST (the server’s IP address or domain name), SERVER_USER (the SSH username, typically root or a deploy user), and SERVER_SSH_KEY (the full private SSH key — generate a dedicated key pair for CI with ssh-keygen and add the public key to the server’s ~/.ssh/authorized_keys).

The deploy script on the server

Create a deploy script that the CI calls:

#!/bin/bash
# /app/deploy.sh
set -e

IMAGE="yourusername/myapp:latest"

echo "[$(date)] Starting deploy..."

# Pull the latest image
docker pull $IMAGE

# Recreate the app container
cd /app
docker compose up -d --no-deps app

# Wait for health check
echo "Waiting for health..."
sleep 15
STATUS=$(docker inspect --format='{{.State.Health.Status}}' app-app-1 2>/dev/null || echo "unknown")

if [ "$STATUS" = "healthy" ]; then
  echo "[$(date)] Deploy successful!"
else
  echo "[$(date)] WARNING: Health check status: $STATUS"
fi

The CI workflow calls this script via SSH instead of running commands inline:

script: |
  bash /app/deploy.sh

Webhook-based deploy (alternative)

Instead of SSH, run a small webhook server on the production server:

# On the server, using webhook (https://github.com/adnanh/webhook)
# hooks.json
[
  {
    "id": "deploy",
    "execute-command": "/app/deploy.sh",
    "command-working-directory": "/app",
    "trigger-rule": {
      "match": {
        "type": "value",
        "value": "your-webhook-secret",
        "parameter": { "source": "header", "name": "X-Deploy-Token" }
      }
    }
  }
]

CI calls the webhook:

- name: Trigger deploy
  run: |
    curl -X POST https://yourserver.com:9000/hooks/deploy \
      -H "X-Deploy-Token: ${{ secrets.DEPLOY_TOKEN }}"

The webhook approach avoids giving CI SSH access to the server. The deploy token is a shared secret that authenticates the deploy request.

The complete workflow

# .github/workflows/deploy.yml
name: Test, Build, Deploy

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22 }
      - run: npm ci
      - run: npx tsx --test tests/*.test.ts

  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}
      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ${{ secrets.DOCKER_USERNAME }}/myapp:latest
            ${{ secrets.DOCKER_USERNAME }}/myapp:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: bash /app/deploy.sh

Three jobs, run in sequence. Tests → build → deploy. A failing test blocks the build. A failing build blocks the deploy.

Exercises

Exercise 1: Add the deploy job to your GitHub Actions workflow. Push to main. Verify the app is updated on the server.

Exercise 2: Push code with a failing test. Verify the deploy does not run.

Exercise 3: Check the deploy logs on the server. Set up a deploy log file: bash /app/deploy.sh >> /app/deploy.log 2>&1.

Why does the deploy job depend on the build job?

← Building Images in CI Container Security →

© 2026 hectoday. All rights reserved.