CI/CDIntermediate 14 min Lesson 5 of 8

Automated Deployment

Turn a passing build into a running deployment: environments, approvals, migrations, health verification and automatic rollback.

CI/CD · Lesson 5 of 8
0/8 done(0%)

What is it? #

Automated deployment takes the artefact the build produced and puts it into an environment, without anyone running commands by hand.

The value is consistency. The same steps happen in the same order every time, which removes the category of incident caused by a forgotten step at eleven at night.

A typical flow deploys to staging automatically, runs smoke tests, then deploys to production either automatically or after an approval.

Two things make it trustworthy: verification after deploying, and an automatic rollback when that verification fails.

Think of it like this #

An automated loading dock rather than a person with a clipboard.

The clipboard works until the person is tired, distracted, or on holiday. The automated version does the same thing at 3am on a Sunday as it does on a Tuesday morning.

Simple example #

A merge to main builds an image, deploys it to staging, runs smoke tests, waits for an approval, deploys to production, verifies health and rolls back automatically if the check fails.

Code #

YAML
  deploy-staging:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.example.com
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        run: |
          ssh -o StrictHostKeyChecking=accept-new deploy@staging \
            "IMAGE=${{ needs.build.outputs.image }} /srv/app/deploy.sh"
      - name: Smoke tests
        run: |
          curl -fsS https://staging.example.com/health
          npx playwright test tests/smoke --reporter=line

  deploy-production:
    needs: [build, deploy-staging]
    runs-on: ubuntu-latest
    environment:
      name: production            # a required reviewer can be configured here
      url: https://example.com
    steps:
      - name: Deploy
        run: |
          ssh deploy@prod "IMAGE=${{ needs.build.outputs.image }} /srv/app/deploy.sh"

      - name: Verify
        id: verify
        run: |
          for i in {1..20}; do
            if curl -fsS https://example.com/health > /dev/null; then
              DEPLOYED=$(curl -fsS https://example.com/version | jq -r .commit)
              [ "<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>D</mi><mi>E</mi><mi>P</mi><mi>L</mi><mi>O</mi><mi>Y</mi><mi>E</mi><mi>D</mi><mi mathvariant="normal">&quot;</mi><mo>=</mo><mi mathvariant="normal">&quot;</mi></mrow><annotation encoding="application/x-tex">DEPLOYED&quot; = &quot;</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:0.6944em;"></span><span class="mord mathnormal" style="margin-right:0.0278em;">D</span><span class="mord mathnormal" style="margin-right:0.0576em;">E</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord mathnormal">L</span><span class="mord mathnormal" style="margin-right:0.0278em;">O</span><span class="mord mathnormal" style="margin-right:0.2222em;">Y</span><span class="mord mathnormal" style="margin-right:0.0576em;">E</span><span class="mord mathnormal" style="margin-right:0.0278em;">D</span><span class="mord">&quot;</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="katex-base"><span class="katex-strut" style="height:0.6944em;"></span><span class="mord">&quot;</span></span></span></span>{{ github.sha }}" ] && exit 0
            fi
            sleep 5
          done
          exit 1

      - name: Roll back on failure
        if: failure() && steps.verify.outcome == 'failure'
        run: ssh deploy@prod "/srv/app/rollback.sh"
BASH
#!/usr/bin/env bash
# /srv/app/deploy.sh on the server — the pipeline calls this
set -euo pipefail
: "${IMAGE:?IMAGE is required}"

docker pull "$IMAGE"

# Migrations first, and they must be backwards compatible
docker run --rm --network app-net --env-file /srv/app/.env "$IMAGE" \
    python manage.py migrate

# Start the new container, wait for health, then switch
docker run -d --name app-new --network app-net --env-file /srv/app/.env "$IMAGE"
for i in {1..30}; do
    docker exec app-new curl -fsS http://localhost:8000/health > /dev/null && break
    [ "$i" = 30 ] && { docker rm -f app-new; echo "unhealthy"; exit 1; }
    sleep 2
done

docker stop -t 30 app-old 2>/dev/null || true
docker rm app-old 2>/dev/null || true
docker rename app app-old 2>/dev/null || true
docker rename app-new app
sudo systemctl reload nginx
TEXT
Deployment gates worth having

before staging      build and all tests passed
before production   staging deployed and smoke tests passed
                    optionally: a human approval
                    optionally: a time window (not Friday evening)
after production    health check and version verification
                    automatic rollback if either fails

How it works #

The deployment job consumes the image reference produced by the build job, so it deploys exactly what was built and tested rather than reconstructing a tag.

GitHub environments provide required reviewers, deployment history and environment-scoped secrets. Configuring production as a protected environment is how an approval step is added without extra tooling.

Smoke tests against staging are a short set of checks covering the critical paths. They are not the full suite; they answer whether the deployment is fundamentally working.

The verification step checks two things: that the health endpoint responds, and that the deployed commit matches the one being deployed. The second catches the case where the deployment silently did nothing and the old version is still serving.

Automatic rollback on verification failure is what makes unattended deployment acceptable. Without it, a failed deployment leaves production broken until someone notices.

On the server, migrations run before the new container starts, which is why they must be backwards compatible — the old version is still serving while they apply.

The container switch starts the replacement, waits for it to become healthy, and only then retires the old one. Stopping first would guarantee downtime.

StrictHostKeyChecking=accept-new accepts a host key on first connection but still fails if a known key changes, which is the right balance for automation.

Real-world use #

Deploying on every merge to main is common and works well when tests are trustworthy and rollback is fast. Teams with less confidence deploy on a schedule with an approval.

Approvals are worth using deliberately rather than by default. An approval that is always granted without inspection adds delay and no safety.

Deployment windows are a real practice: many teams avoid Friday afternoon deployments not because the code is riskier, but because the people who can fix it are about to be unavailable.

Notifying a chat channel on deployment start, success and failure gives the team shared awareness at almost no cost.

The deployment script on the server is the same one a human would run manually. Keeping it in the repository means it is reviewed, versioned and testable rather than existing only on the machine.

Common mistakes #

  • Deploying without verifying the new version is actually serving.
  • No automatic rollback, so a failed deployment leaves production broken.
  • Reconstructing the artefact tag instead of using the build output.
  • Approvals that are granted reflexively, adding delay without safety.
  • Deployment steps that exist only on the server, unreviewed and unversioned.

Practice #

Write a deployment job that consumes the build artefact, deploys to staging, runs a smoke test, then deploys to production with health and version verification and an automatic rollback step. Break the health endpoint deliberately and confirm the rollback runs.

Quick quiz

  1. 1. Why should the deployment job use the build job’s output?

  2. 2. Why verify the deployed commit and not just the health endpoint?

  3. 3. Why must migrations be backwards compatible?

  4. 4. What makes unattended deployment acceptable?

  5. 5. When is an approval step counterproductive?

Summary

  • Deploy the exact artefact the build produced.
  • Deploy to staging, smoke test, then production with optional approval.
  • Verify both health and the deployed version after deploying.
  • Roll back automatically when verification fails.
  • Keep deployment scripts in the repository, not only on the server.