DevOps.Academy
CoursesCurriculumPricing

    Loading…

    ↑ ↓ move · ↵ open · Ctrl K anywhere

    ◔My learningSign in
    DevOps.Academy

    Free, hands-on DevOps lessons. Made with care in India.

    Learn

    All coursesLinux roadmapCurriculumMy learning

    Academy

    PricingCertificates

    Legal

    PrivacyTermsContact

    devops.rajeev.pro

    CI/CD PipelinesLesson 7 of 12
    Your progress · 0%
    On this page
    1. Three targets, one rule
    2. Static sites
    3. Deploy to a server over SSH
    4. Deploy to Kubernetes
    5. Deployment strategies
    6. Rollbacks: decide before you need one
    ↖ Course roadmap
    Lesson 7 · Deploy

    Deploy: static sites, servers over SSH, Kubernetes, and rollbacks

    Ship the artifact you built: a static site to GitHub Pages, a release to a server over SSH with an atomic switch and automatic rollback, and an image to Kubernetes with a safe rolling update.

    45 min read · intermediate · hands-on

    By the end of this lesson you'll be able to

    0 of 7 complete

    Three targets, one rule

    Wherever it runs, a deploy does the same four things: get the artifact you already built and tested (lessons 3 and 6), put it on the target, switch traffic to it, and prove it works, undoing the switch if it doesn't:

    Get the tested artifact, put it in place, switch, prove it. If the proof fails, switch back automatically.© Diagram: DevOps Academy

    Get the tested artifact, put it in place, switch, prove it. If the proof fails, switch back automatically.© Diagram: DevOps Academy

    Static sites

    A static site (HTML, CSS, JavaScript, images) is the simplest deploy: upload the built folder. GitHub Pages takes it straight from a workflow:

    name: Docs site
    
    on:
      push:
        branches: [main]
        paths: ['docs/**']
    
    permissions:
      contents: read
    
    concurrency:
      group: pages
      cancel-in-progress: false
    
    jobs:
      build:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v7
          - run: mkdir -p public && cp -r docs/* public/
          - uses: actions/upload-pages-artifact@v5
            with:
              path: public
    
      deploy:
        needs: build
        runs-on: ubuntu-latest
        permissions:
          pages: write
          id-token: write
        environment:
          name: github-pages
          url: ${{ steps.deployment.outputs.page_url }}
        steps:
          - id: deployment
            uses: actions/deploy-pages@v5
    

    Set Settings → Pages → Source to GitHub Actions once. For a real site, replace the cp with your build (npm run build). Netlify, Vercel and Cloudflare Pages work the same way, and add a preview URL for every pull request. This site is deployed exactly like that: previews on pull requests, staging from main, production from a GitHub Release (the Git course's Tags and releases lesson).

    Deploy to a server over SSH

    For an app on a Linux server, the safe pattern keeps every release in its own folder and points a current symlink at the live one:

    /srv/shop-api/
    ├── releases/
    │   ├── 1.0.42/
    │   └── 1.0.43/        ← new
    ├── current -> releases/1.0.43
    └── restart.sh         # sudo systemctl restart shop-api
    

    This script uploads a release tarball, switches atomically, health-checks, and rolls back by itself:

    #!/usr/bin/env bash
    # Deploy a release tarball to a server over SSH, switch to it atomically, and roll back if it's unhealthy.
    # usage: deploy-ssh.sh <user@host> <version> <tarball>
    set -euo pipefail
    host=${1:?usage: deploy-ssh.sh <user@host> <version> <tarball>}
    version=${2:?version required}
    tarball=${3:?tarball required}
    ssh_opts=(-o BatchMode=yes ${SSH_PORT:+-p "$SSH_PORT"} ${SSH_KEY:+-i "$SSH_KEY"})
    app=/srv/shop-api
    health_url=${HEALTH_URL:-http://localhost:3000/health}
    
    remote() { ssh "${ssh_opts[@]}" "$host" "$@"; }
    
    echo "1. upload $version"
    remote "mkdir -p $app/releases/$version"
    ssh "${ssh_opts[@]}" "$host" "tar -xzf - -C $app/releases/$version" < "$tarball"
    
    echo "2. switch current -> $version"
    previous=$(remote "readlink $app/current || true")
    remote "ln -sfn $app/releases/$version $app/current.new && mv -T $app/current.new $app/current && $app/restart.sh"
    
    echo "3. health check"
    for i in $(seq 1 10); do
      if remote "curl -fsS $health_url" > /dev/null 2>&1; then
        echo "   healthy: $(remote "curl -fsS $health_url")"
        remote "ls -1dt $app/releases/* | tail -n +6 | xargs -r rm -rf"   # keep the last 5 releases
        exit 0
      fi
      sleep 1
    done
    
    echo "4. unhealthy: rolling back to ${previous:-nothing}" >&2
    if [[ -n $previous ]]; then
      remote "ln -sfn $previous $app/current.new && mv -T $app/current.new $app/current && $app/restart.sh"
    fi
    exit 1
    

    Here it is against a test server: a good release, then one that crashes on start:

    1.0.43 goes live. 1.0.44 crashes, fails its health check, and the script puts 1.0.43 back by itself and exits 1, so CI turns red.© Screenshot: DevOps Academy

    1.0.43 goes live. 1.0.44 crashes, fails its health check, and the script puts 1.0.43 back by itself and exits 1, so CI turns red.© Screenshot: DevOps Academy

    After the rollback, the app answered {"status":"ok","version":"1.0.43"} from outside the server. Users saw a restart, not an outage.

    DetailWhy
    ln -sfn … current.new && mv -T current.new currentmv is one atomic rename: there's no moment without a current
    keep the previous path before switchingthe rollback target is known before anything changes
    restart through one script (systemctl restart shop-api)the same command for deploys and rollbacks
    exit 1 after a rollbackthe pipeline goes red even though users are fine again
    keep the last 5 releasesinstant manual rollback further back, without filling the disk
    Detach the restarted app from SSH

    If a restart starts the app in the background over SSH (nohup node … &), the SSH session waits for it and the deploy hangs. Redirect all three streams (> app.log 2>&1 < /dev/null) or, better, let systemd run the app (the Linux course's Services with systemd lesson), so the restart returns immediately.

    Running it from CI

    - name: Trust only our server's host key
      env:
        SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
        KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
      run: |
        install -d -m 700 ~/.ssh
        printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
        printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
    - name: Deploy
      run: ./scripts/deploy-ssh.sh deploy@shop.example.com "$VERSION" shop-api-*.tgz
    

    Get DEPLOY_KNOWN_HOSTS once with ssh-keyscan -t ed25519 shop.example.com, check its fingerprint against the server, and store it as a secret. Never use StrictHostKeyChecking=no: it hands your deploy key to whatever machine answers. The deploy key belongs to a deploy user that can write /srv/shop-api and restart one service, nothing else. Put the job behind the production environment with reviewers (lesson 4).

    Deploy to Kubernetes

    One new pod at a time, and an old one stops only after the new one is Ready. A crashing release never takes traffic.© Animation: DevOps Academy

    One new pod at a time, and an old one stops only after the new one is Ready. A crashing release never takes traffic.© Animation: DevOps Academy

    On Kubernetes, a deploy is changing the image of a Deployment. Kubernetes replaces pods gradually, and only sends traffic to a new pod once its readiness probe passes:

    spec:
      replicas: 3
      progressDeadlineSeconds: 60
      strategy:
        type: RollingUpdate
        rollingUpdate:
          maxUnavailable: 0        # never fewer than 3 ready pods
          maxSurge: 1              # one extra new pod at a time
      template:
        spec:
          containers:
            - name: shop-api
              image: ghcr.io/elonerajeev/shop-api:1.0.42-e3b0c44
              readinessProbe:
                httpGet: { path: /health, port: 3000 }
    
    $ kubectl set image deployment/shop-api shop-api=ghcr.io/elonerajeev/shop-api:1.0.43-9f2a1bc
    $ kubectl rollout status deployment/shop-api --timeout=120s    # waits; exits non-zero if it fails
    $ kubectl rollout history deployment/shop-api
    $ kubectl rollout undo deployment/shop-api                     # back to the previous ReplicaSet
    
    kubectl set image deployment/shop-api shop-api=ghcr.io/elonerajeev/shop-api:1.0.43-9f2a1bc
    • kubectltalk to a Kubernetes cluster
    kubectl rollout status deployment/shop-api --timeout=120s
    • rolloutmanage a rollout

    With maxUnavailable: 0, a broken image can't take the service down: its first new pod never becomes ready, the rollout stops, and the three old pods keep serving. rollout status fails after progressDeadlineSeconds, which fails the pipeline, and rollout undo cleans up. With GitOps (lesson 10), CI doesn't run kubectl at all: it commits the new tag and Argo CD or Flux rolls it out the same way.

    Deployment strategies

    Every deploy answers one question: how many users see the new version, and how quickly? The rolling update above is one answer. There are three more, and teams mix them:

    StrategyTraffic on the new versionRoll back byExtra costWatch out for
    Rollinggrows pod by podanother rollout, in minutesone extra podtwo versions serve at the same time
    Blue/green0%, then 100%switching back, in secondsa second full environmentthe shared database must suit both
    Canarya slice, then moresetting its weight to 0a littleyou need metrics to judge the canary
    Feature flagchosen users, in the codeturning the flag offnone in infrastructureold flags pile up as dead code

    Blue/green moves everyone in one switch and keeps the old environment for rollback. A canary moves a slice at a time, so a bad release only reaches that slice.© Animation: DevOps Academy

    Blue/green moves everyone in one switch and keeps the old environment for rollback. A canary moves a slice at a time, so a bad release only reaches that slice.© Animation: DevOps Academy

    Rolling

    The default on Kubernetes and most platforms, shown in the animation above: replace instances a few at a time, each only after the new one is healthy. It costs almost nothing extra. The catch is that for a few minutes both versions serve requests, so version 1.0.44 must not break clients or data that 1.0.43 still uses.

    Blue-green

    Run two identical environments. One (blue) takes all traffic; deploy the new version to the idle one (green), test it with no users on it, then repoint the load balancer. Rollback is the same switch backwards, in seconds, because blue is still running. Here it is with three local nginx containers, lb in front:

    # conf/default.conf, mounted into the lb container
    upstream shop_api {
        server blue:80;
    }
    server {
        listen 80;
        location / { proxy_pass http://shop_api; }
    }
    
    $ for i in 1 2 3; do curl -s localhost:8088; done
    shop-api 1.0.43 (blue)
    shop-api 1.0.43 (blue)
    shop-api 1.0.43 (blue)
    $ sed -i 's/server blue:80;/server green:80;/' conf/default.conf
    $ docker exec lb nginx -s reload
    $ for i in 1 2 3; do curl -s localhost:8088; done
    shop-api 1.0.44 (green)
    shop-api 1.0.44 (green)
    shop-api 1.0.44 (green)
    
    for i in 1 2 3; do curl -s localhost:8088; done
    • forloop over a list
    • dostart of what the loop runs each time
    • curlmake an HTTP request
    • -ssilent: no progress bar
    • doneend of the loop
    docker exec lb nginx -s reload
    • dockerbuild and run containers
    • execrun a command in a running container
    • lbthe container
    • nginx -s reloadthe command to run inside it

    Mount the folder, not the single file: sed -i writes a new file, and a single-file bind mount keeps showing the container the old one.

    The price is a second environment, at least during the switch, and one hard rule: both colours share the database, so a schema change must work for blue and green at once. On AWS the switch is usually an ALB listener moving to another target group (CodeDeploy automates it for ECS); on Kubernetes, a Service whose selector moves from version: blue to version: green.

    Canary

    Send a small share of traffic to the new version, compare its errors and latency with the old one, then widen in steps, or stop. The same containers, with weights:

    upstream shop_api {
        server blue:80  weight=9;    # stable, 1.0.43
        server green:80 weight=1;    # canary, 1.0.44
    }
    
    $ docker exec lb nginx -s reload
    $ for i in $(seq 100); do curl -s localhost:8088; done | sort | uniq -c
         90 shop-api 1.0.43 (blue)
         10 shop-api 1.0.44 (green)
    
    docker exec lb nginx -s reload
    • dockerbuild and run containers
    • execrun a command in a running container
    • lbthe container
    • nginx -s reloadthe command to run inside it
    for i in $(seq 100); do curl -s localhost:8088; done | sort | uniq -c
    • forloop over a list
    • $(seq 100);the output of another command
    • dostart of what the loop runs each time
    • curlmake an HTTP request
    • -ssilent: no progress bar
    • doneend of the loop
    • |pipe: send output to the next command
    • sortsort lines
    • uniqdrop repeated lines
    • -ccount each one

    A canary is only as good as what you watch: the canary's error rate and latency next to the stable version's, over enough requests to mean something. Weights split requests, not users, so one person can bounce between versions; split by a cookie or user ID when that matters. Argo Rollouts and Flagger run the steps and the checks for you on Kubernetes, and roll back on their own when a metric goes bad.

    Feature flags: deploy is not release

    The strategies above move traffic between versions. A feature flag moves behaviour inside one version: the new code ships switched off, and turning it on is the release. That lets you merge unfinished work to main daily, turn a feature on for 1% of users, and turn it off in seconds without a deploy.

    // flags.js: same user, same answer every time
    function isOn(name, userId) {
      const f = flags[name]
      if (!f || !f.enabled) return false
      const bucket = createHash('sha256').update(`${name}:${userId}`).digest().readUInt32BE(0) % 100
      return bucket < f.percent
    }
    
    $ cat flags.json
    { "new-checkout": { "enabled": true, "percent": 10 } }
    $ node flags.js
    new-checkout on for 101 of 1000 users
    user-4 sees new checkout: true
    $ sed -i 's/"percent": 10/"percent": 50/' flags.json
    $ node flags.js
    new-checkout on for 480 of 1000 users
    user-4 sees new checkout: true
    $ sed -i 's/"enabled": true/"enabled": false/' flags.json
    $ node flags.js
    new-checkout on for 0 of 1000 users
    user-4 sees new checkout: false
    
    cat flags.json
    • catprint a file

    Hashing the user ID keeps each user in the same bucket, so user-4, inside the first 10%, stays in as the rollout widens to 50%. enabled: false is the kill switch. Real projects keep flags in a service (LaunchDarkly, Unleash, or anything behind the OpenFeature standard) so they change without a deploy. Delete a flag once it's fully on: every flag left behind is a code path nobody tests.

    Rollbacks: decide before you need one

    DeployRoll back byTime
    Static siteredeploy the previous build, or revert and let CI redeploya minute
    Server, symlink releasesrepoint current to the previous release, restartseconds
    Kuberneteskubectl rollout undo, or redeploy the previous taga minute
    GitOpsgit revert the tag change in the config repositorya sync interval
    Database migrationa forward fix: migrations rarely undo safelyplan it in advance

    Two habits make rollbacks boring. Keep every deploy reversible: database changes go out in backward-compatible steps (add a column, deploy, then remove the old one later), so the previous version still works. And practise: roll back on staging now and then, so the command is known before a 3 am incident.

    In production

    Deploy small changes often, behind the production environment's approval. Watch error rates for a few minutes after each deploy (the Monitoring course), and roll back first, investigate second. A deploy you can't undo in one command isn't finished.

    ⌘
    Mini mission

    Deploy shop-api to a server, and survive a bad release

    Package shop-api as a release tarball and deploy it to a Linux server with the SSH script: a good release, a second good one, then one that crashes on start. Prove the script rolled back by itself and that the app still answers.

    Input: A Linux VM you can SSH to as a deploy user, with Node 22 and curl; the shop-api repositoryOutput: current pointing at the last good release, and a red exit code for the bad one
    Reveal one safe solution
    $ ssh deploy@<vm> 'mkdir -p /srv/shop-api/releases'
    $ ssh deploy@<vm> 'cat > /srv/shop-api/restart.sh' <<'EOF'
    #!/usr/bin/env bash
    sudo systemctl restart shop-api
    EOF
    $ ssh deploy@<vm> 'chmod +x /srv/shop-api/restart.sh'
    $ mkdir -p dist && cp -r src package.json dist/ && tar -czf shop-api-1.0.42.tgz -C dist .
    $ cp shop-api-1.0.42.tgz shop-api-1.0.43.tgz
    $ echo 'throw new Error("boom: missing config")' >> dist/src/server.js && tar -czf shop-api-1.0.44.tgz -C dist .
    $ for v in 1.0.42 1.0.43 1.0.44; do ./scripts/deploy-ssh.sh deploy@<vm> $v shop-api-$v.tgz; echo "exit=$?"; done
    $ ssh deploy@<vm> 'readlink /srv/shop-api/current && curl -s localhost:3000/health'
    /srv/shop-api/releases/1.0.43
    {"status":"ok","version":"1.0.43"}
    
    ssh deploy@<vm> 'mkdir -p /srv/shop-api/releases'
    • sshlog in to another machine securely
    mkdir -p dist && cp -r src package.json dist/ && tar -czf shop-api-1.0.42.tgz -C dist .
    • mkdirmake a directory
    • -pcreate missing parents, no error if it exists
    • &&run the next command only if this one worked
    • cpcopy files
    • -rcopy folders and everything inside
    • tarpack or unpack archives
    • .this folder
    echo 'throw new Error("boom: missing config")' >> dist/src/server.js && tar -czf shop-api-1.0.44.tgz -C dist .
    • echoprint text
    • >>append output to a file
    for v in 1.0.42 1.0.43 1.0.44; do ./scripts/deploy-ssh.sh deploy@<vm> $v shop-api-$v.tgz; echo "exit=$?"; done
    • forloop over a list
    • dostart of what the loop runs each time
    • doneend of the loop

    The service unit (/etc/systemd/system/shop-api.service) runs node /srv/shop-api/current/src/server.js as the deploy user, and a sudoers rule lets deploy run exactly systemctl restart shop-api (the Linux course's Users and groups and Services lessons).

    Try it yourself

    0 of 7 steps done

    Run each task in your own account or terminal, then tick it off.

    Knowledge check

    Quick check

    Question 1 of 7

    Why switch releases with a symlink instead of copying files over the running app?

    Go deeper

    Official reading for this lesson

    • DocsGitHub Docs: using custom workflows with GitHub PagesGitHub Docs ↗
    • DocsGitHub Docs: using workflow_run to trigger a workflowGitHub Docs ↗
    • DocsKubernetes Docs: Deployments and rolling updatesKubernetes ↗
    • Docskubectl rollout referenceKubernetes ↗
    • Docsssh-keyscan(1) manualUbuntu manpages ↗

    Or get every quiz answer right and it completes itself.

    ← PreviousBuild and publish: images you can trust and find againNext lesson →Jenkins: the controller, agents, Jenkinsfile and shared libraries