Deployment workflows automate the promotion of verified Docker images through environments — from staging (for final QA) to production (for users). A well-designed deployment workflow requires a manual approval gate before production, maintains a full deployment history for rollbacks, notifies the team of deployment status, and can be triggered both automatically (on merge to main) and manually (for emergency hotfixes). This lesson builds the complete deployment pipeline for the MEAN Stack task manager.
Deployment Strategy Patterns
| Pattern | Behaviour | Downtime | Complexity |
|---|---|---|---|
| Recreate | Stop old, start new | Brief downtime during restart | Simple |
| Rolling update | Replace instances one by one | Zero (if multiple replicas) | Medium |
| Blue-Green | Deploy alongside old, switch traffic atomically | Zero | High — double infrastructure |
| Canary | Route small % of traffic to new version, increase gradually | Zero | High — requires load balancer |
GitHub Environments
| Feature | Purpose |
|---|---|
| Environment secrets | Secrets scoped to one environment — staging and production have different values |
| Protection rules | Required reviewers — a human must approve before the deployment job runs |
| Deployment history | GitHub tracks every deployment — shows which SHA is deployed where, enables rollback |
| Wait timer | Delay before deployment — gives time to cancel if something seems wrong |
| Branch restrictions | Only specific branches can deploy to this environment |
environment: production. Required reviewers configured on the environment create a pending approval step — the workflow pauses until an authorised reviewer approves or rejects in the GitHub UI. This provides the human approval gate that separates a staging deployment from a production deployment.workflow_dispatch inputs for manual deployment workflows to specify which image tag to deploy. A dropdown input with type: choice or a free-text input for the SHA allows on-call engineers to deploy a specific version without modifying YAML: inputs: image-tag: description: "Image tag to deploy" required: true default: "main". This is also the rollback mechanism — deploy a previous SHA by entering it in the workflow dispatch form.
Complete Deployment Workflow
# .github/workflows/deploy.yml — Multi-environment deployment pipeline
name: Deploy
on:
workflow_run:
workflows: [CI, Docker Build & Push]
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
type: choice
options: [staging, production]
default: staging
image-tag:
description: 'Image tag to deploy (default: latest main SHA)'
required: false
env:
REGISTRY: ghcr.io
API_IMAGE: ghcr.io/${{ github.repository }}/api
CLIENT_IMAGE: ghcr.io/${{ github.repository }}/client
jobs:
# ── Resolve which image tag to deploy ────────────────────────────────
resolve-tag:
name: Resolve Image Tag
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.resolve.outputs.tag }}
steps:
- name: Resolve tag
id: resolve
run: |
if [ -n "${{ github.event.inputs.image-tag }}" ]; then
echo "tag=${{ github.event.inputs.image-tag }}" >> $GITHUB_OUTPUT
else
echo "tag=sha-$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
fi
# ── Deploy to Staging ─────────────────────────────────────────────────
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
needs: resolve-tag
environment: staging # uses staging secrets + deployment tracking
concurrency:
group: deploy-staging
cancel-in-progress: false # don't cancel in-progress deployments!
steps:
- uses: actions/checkout@v4
- name: Deploy to staging server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.STAGING_HOST }}
username: ${{ secrets.STAGING_USER }}
key: ${{ secrets.STAGING_SSH_KEY }}
script: |
cd /opt/taskmanager
export API_IMAGE=${{ env.API_IMAGE }}:${{ needs.resolve-tag.outputs.tag }}
export CLIENT_IMAGE=${{ env.CLIENT_IMAGE }}:${{ needs.resolve-tag.outputs.tag }}
export GIT_SHA=${{ github.sha }}
# Pull new images
docker pull $API_IMAGE
docker pull $CLIENT_IMAGE
# Deploy with rolling update
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d \
--no-build \
api angular
# Verify deployment health
sleep 10
curl -f http://localhost:3000/api/v1/health || exit 1
echo "Staging deployment successful: $GIT_SHA"
- name: Run smoke tests against staging
run: |
response=$(curl -s -o /dev/null -w "%{http_code}" \
https://staging.taskmanager.io/api/v1/health)
if [ "$response" != "200" ]; then
echo "Smoke test failed: HTTP $response"
exit 1
fi
echo "Smoke test passed"
- name: Notify staging deployment
uses: slackapi/slack-github-action@v1
with:
channel-id: ${{ secrets.SLACK_CHANNEL_ID }}
slack-message: |
✅ Staging deployed: `${{ needs.resolve-tag.outputs.tag }}`
Commit: ${{ github.event.head_commit.message }}
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
# ── Deploy to Production (requires manual approval) ───────────────────
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
needs: [resolve-tag, deploy-staging] # staging must succeed first
environment: production # has required reviewers configured
concurrency:
group: deploy-production
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- name: Create GitHub deployment
uses: actions/github-script@v7
id: create-deployment
with:
script: |
const deployment = await github.rest.repos.createDeployment({
owner: context.repo.owner,
repo: context.repo.repo,
ref: '${{ github.sha }}',
environment: 'production',
description: 'Deploying ${{ needs.resolve-tag.outputs.tag }}',
auto_merge: false,
});
core.setOutput('deployment_id', deployment.data.id);
- name: Deploy to production
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script: |
cd /opt/taskmanager
export API_IMAGE=${{ env.API_IMAGE }}:${{ needs.resolve-tag.outputs.tag }}
export CLIENT_IMAGE=${{ env.CLIENT_IMAGE }}:${{ needs.resolve-tag.outputs.tag }}
# Save current tag for rollback
docker inspect $(docker compose ps -q api) \
--format '{{.Config.Image}}' > /opt/taskmanager/.last-deployed-tag
docker pull $API_IMAGE
docker pull $CLIENT_IMAGE
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d \
--no-build api angular
sleep 15
curl -f https://api.taskmanager.io/api/v1/health || exit 1
- name: Update deployment status — success
if: success()
uses: actions/github-script@v7
with:
script: |
github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: ${{ steps.create-deployment.outputs.deployment_id }},
state: 'success',
environment_url: 'https://app.taskmanager.io',
});
- name: Update deployment status — failure
if: failure()
uses: actions/github-script@v7
with:
script: |
github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: ${{ steps.create-deployment.outputs.deployment_id }},
state: 'failure',
});
How It Works
Step 1 — Environment Protection Rules Gate Production
When a job targets environment: production, GitHub checks the environment’s protection rules. If “Required reviewers” is configured (in Settings → Environments → production), the job pauses and GitHub sends review requests to the designated reviewers. The workflow shows a “Review pending” status in the UI. Only after an authorised reviewer clicks “Approve” does the deployment job continue. This human checkpoint is the last line of defence before code reaches users.
Step 2 — Staging Must Pass Before Production is Available
needs: [resolve-tag, deploy-staging] on the production job means: production deployment only becomes available after staging deployment succeeds. If the staging deployment fails (smoke test fails, SSH error, Docker pull fails), the production job is skipped entirely. This enforces the invariant: code must work in staging before it can reach production.
Step 3 — concurrency: cancel-in-progress: false Protects Deployments
For deployment jobs, cancel-in-progress: false is critical. If two deployments are triggered in quick succession, you do not want to cancel a deployment mid-flight — the server could be left in a half-deployed state. The default for CI jobs is to cancel on new pushes (saving compute), but for deployments, queue them instead. The second deployment waits for the first to complete before starting.
Step 4 — Saving the Previous Tag Enables Fast Rollback
Saving the current image tag before deploying the new one provides instant rollback capability: if the new deployment fails or causes errors, run the deployment workflow with the saved previous tag. Storing it in a file on the server (.last-deployed-tag) means the rollback information survives CI pipeline failures. The GitHub Deployments API also tracks this — the deployment history in the GitHub UI shows which SHA is deployed where.
Step 5 — Smoke Tests Catch Deployment Failures Immediately
A smoke test runs immediately after deployment to verify the application is actually responding correctly — not just that the Docker commands succeeded. A simple curl -f https://api.taskmanager.io/api/v1/health verifies the API container started, connected to MongoDB, and is responding to HTTP requests. A failed smoke test triggers an immediate alert and blocks the production deployment gate, preventing broken deployments from being confirmed as successful.
Common Mistakes
Mistake 1 — Deploying to production without staging first
❌ Wrong — code goes directly to production without environment verification:
deploy-production:
needs: resolve-tag # no staging dependency!
environment: production
✅ Correct — staging must succeed before production is available:
deploy-production:
needs: [resolve-tag, deploy-staging] # staging required
environment: production
Mistake 2 — Using cancel-in-progress: true on deployments
❌ Wrong — mid-flight deployment is cancelled, server in unknown state:
concurrency:
group: deploy-production
cancel-in-progress: true # cancels deployment mid-flight!
✅ Correct — queue deployments, never cancel in flight:
concurrency:
group: deploy-production
cancel-in-progress: false # queue — second waits for first to complete
Mistake 3 — No smoke test after deployment
❌ Wrong — deployment “succeeds” even if app is broken:
- name: Deploy
run: docker compose up -d
# Docker started the container — but is the app actually working?
# No verification — deployment marked successful even if it crashes
✅ Correct — verify health after deployment:
sleep 10
curl -f https://api.example.com/health || exit 1 # fail job if unhealthy
Quick Reference
| Task | YAML / Command |
|---|---|
| Target environment | environment: production |
| Require staging first | needs: [deploy-staging] |
| Queue (not cancel) deploys | concurrency: cancel-in-progress: false |
| SSH deploy | appleboy/ssh-action@v1 |
| Deploy specific image | workflow_dispatch inputs: image-tag |
| Smoke test | curl -f https://host/health || exit 1 |
| Track deployment | github.rest.repos.createDeployment / createDeploymentStatus |
| Slack notification | slackapi/slack-github-action@v1 |