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.
By the end of this lesson you'll be able to
0 of 7 completeThree 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:
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:
After the rollback, the app answered {"status":"ok","version":"1.0.43"} from outside the server. Users saw a
restart, not an outage.
| Detail | Why |
|---|---|
ln -sfn … current.new && mv -T current.new current | mv is one atomic rename: there's no moment without a current |
| keep the previous path before switching | the 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 rollback | the pipeline goes red even though users are fine again |
| keep the last 5 releases | instant manual rollback further back, without filling the disk |
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
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-9f2a1bckubectltalk to a Kubernetes cluster
kubectl rollout status deployment/shop-api --timeout=120srolloutmanage 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:
| Strategy | Traffic on the new version | Roll back by | Extra cost | Watch out for |
|---|---|---|---|---|
| Rolling | grows pod by pod | another rollout, in minutes | one extra pod | two versions serve at the same time |
| Blue/green | 0%, then 100% | switching back, in seconds | a second full environment | the shared database must suit both |
| Canary | a slice, then more | setting its weight to 0 | a little | you need metrics to judge the canary |
| Feature flag | chosen users, in the code | turning the flag off | none in infrastructure | old flags pile up as dead code |
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; doneforloop over a listdostart of what the loop runs each timecurlmake an HTTP request-ssilent: no progress bardoneend of the loop
docker exec lb nginx -s reloaddockerbuild and run containersexecrun a command in a running containerlbthe containernginx -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 reloaddockerbuild and run containersexecrun a command in a running containerlbthe containernginx -s reloadthe command to run inside it
for i in $(seq 100); do curl -s localhost:8088; done | sort | uniq -cforloop over a list$(seq 100);the output of another commanddostart of what the loop runs each timecurlmake an HTTP request-ssilent: no progress bardoneend of the loop|pipe: send output to the next commandsortsort linesuniqdrop 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.jsoncatprint 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
| Deploy | Roll back by | Time |
|---|---|---|
| Static site | redeploy the previous build, or revert and let CI redeploy | a minute |
| Server, symlink releases | repoint current to the previous release, restart | seconds |
| Kubernetes | kubectl rollout undo, or redeploy the previous tag | a minute |
| GitOps | git revert the tag change in the config repository | a sync interval |
| Database migration | a forward fix: migrations rarely undo safely | plan 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.
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.
current pointing at the last good release, and a red exit code for the bad oneReveal 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 workedcpcopy files-rcopy folders and everything insidetarpack 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=$?"; doneforloop over a listdostart of what the loop runs each timedoneend 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 doneRun each task in your own account or terminal, then tick it off.
Quick check
Why switch releases with a symlink instead of copying files over the running app?
Official reading for this lesson
Or get every quiz answer right and it completes itself.