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?