Auto-Deploy on Merge
Right now, shipping a change means: SSH in, git pull, recreate the containers, by hand, every time.
That's fine once; it's friction forever, and friction is where bugs and stale deploys live. The finish
line is merge to main → it's live, automatically. We'll use GitHub Actions; for how pipelines work
in general, Your First Pipeline (GitHub Actions) and
What a CI/CD Pipeline Actually Does are the foundations - here's the deploy-over-SSH
shape specifically.
The shape: push → Action → SSH → pull + recreate
On every push to main, a workflow runs on GitHub's machines, opens an SSH connection to your box using a
key you've stored as a secret, and runs the same commands you'd type by hand.
The deploy key and the secret
Give the Action its own way in (never your personal key):
- Generate a deploy SSH key pair for CI. Put the public half in the box's
~/.ssh/authorized_keys(so the Action can log in), and store the private half in GitHub → repo → Settings → Secrets and variables → Actions as, say,DEPLOY_SSH_KEY. AddDEPLOY_HOSTandDEPLOY_USERtoo. - Secrets are encrypted and injected at run time - never printed, never committed.
The workflow
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: # only merges/pushes to main deploy
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: SSH in and update the stack
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
cd ~/your-project
git pull
docker compose up -d --force-recreate
What just happened: on a push to main, GitHub spins up a runner, connects to your box with the secret
key, and runs the deploy script - git pull to fetch the merged code, then docker compose up -d --force-recreate to bring the stack up on it. From now on, merging is deploying. No SSH, no
remembering the commands.
⚠️ The two traps, one last time
⚠️ --force-recreate, not restart. The script uses docker compose up -d --force-recreate on
purpose. If it ran docker compose restart, a deploy that only changed an env value or rebuilt an image
would silently keep running the old containers - the Action goes green, and nothing actually changed.
This is the Phase 3 trap, now automated: recreate, don't restart.
⚠️ Don't build on the tiny box if you can help it. git pull + docker compose up -d --build builds
on the server - straight back into the Phase 1 OOM risk, now on every deploy. Two
better shapes:
- Build in the Action, pull the image on the box. The runner has plenty of RAM: build the image there,
push it to a registry, and have the box's compose file just pull it (
docker compose pull && up -d). No build on the box, ever. - Or keep building on the box but guarantee swap (Phase 1) so the build can't get OOM-killed mid-deploy.
💡 Key point. A deploy should be boring. The recipe - pull, recreate, prefer a pre-built image - makes every future change a merge and nothing more.
You shipped it
Step back at what you built across these six phases: a box you chose wisely and won't get surprise-billed for, a locked-down SSH key login, your private app running in Docker, a real domain, free HTTPS behind Cloudflare with the cookie/CSRF/firewall traps handled - and now a one-merge deploy. The gap between "works on my laptop" and "a stranger uses it at a URL" is closed, and you know where every trap was hiding.
The next time someone says "just deploy it," that word won't make your stomach drop. You've done the whole journey once, with eyes open - and that's the only time it's ever hard.
Recap
- GitHub Actions on push to
mainSSHes into the box (with a deploy key in Secrets) and runs your deploy script - merging becomes deploying. - ⚠️ Use
docker compose up -d --force-recreate, notrestart, or deploys silently keep the old containers. - ⚠️ Avoid building on the tiny box - build in the Action and pull the image, or guarantee swap so the deploy build can't OOM.
← Phase 5: Behind Cloudflare · Guide overview
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. What is the shape of auto-deploy on merge with GitHub Actions?
2. Why must the deploy script use `--force-recreate` rather than `restart`?
3. What is the better way to avoid OOM on every deploy to a tiny box?