18 — Deploying from CI
Read this first: this lesson connects the two halves of the course — a workflow that builds an image, and a server that runs it. It covers SSH from a runner, the difference between interpolating a secret and passing it as an environment variable (which is a security boundary, not a style choice), and how configuration reaches a server that stores none of its own.
Time: about 50 minutes. Assumes lesson 10, lesson 16 and lesson 17.
The shape
deploy:
needs: build
runs-on: ubuntu-latest
steps:
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT || 22 }}
command_timeout: 20m
script: |
set -euo pipefail
cd /srv/greeter
./deploy.sh "${{ needs.build.outputs.tag }}"
That is lesson 10's ssh host 'command' with the key coming from a secret.
Nothing new is happening — the runner opens an SSH connection, runs a script, and the step is red if
the script exits non-zero.
${{ secrets.VPS_PORT || 22 }} is the default-value idiom: use the secret if set, otherwise 22.
Four secrets are needed, and their names are worth learning because they are the same everywhere:
| Secret | Is |
|---|---|
VPS_HOST | The server's IP address |
VPS_USER | The deploy user |
VPS_SSH_KEY | The private key, whole file including the header lines |
VPS_PORT | Optional |
VPS_HOST must be an IP, not the domain. In production the domain resolves to the CDN, and a CDN
does not proxy SSH. This catches people every time.
And from lesson 11: since the deploy user is in the docker group,
VPS_SSH_KEY is effectively a root credential for that machine.
Break it on purpose: interpolating a secret into a script
This is the part that is a security boundary rather than a preference.
Predict: your env file contains multiple lines, some with quotes, dollar signs and backticks. What happens if you write it into the script like this?
script: |
printf '%s\n' "${{ secrets.PROD_ENV_FILE }}" > .env
${{ }} is textual substitution performed by GitHub before the script exists. The secret's
literal content is pasted into the script body. A multi-line value breaks the quoting immediately;
a value containing a backtick or $(...) becomes executable code on your server.
The fix is to pass it as a real environment variable:
env:
PROD_ENV: ${{ secrets.PROD_ENV_FILE }}
with:
envs: PROD_ENV,STAGE_ENV
script: |
set -euo pipefail
cd /srv/motorph
umask 077
[ -n "$PROD_ENV" ] || { echo "PROD_ENV_FILE secret is empty/unset" >&2; exit 1; }
printf '%s\n' "$PROD_ENV" > .env
Now the script text contains $PROD_ENV — five characters — and the value travels separately as an
environment variable on the remote shell. Content and code never mix.
That is exactly what deploy.yml does, and every piece of it earns its place:
envs: PROD_ENV,STAGE_ENVtells the action which variables to forward.umask 077before the redirect makes.envland mode600, from lesson 11. Notchmodafterwards — that leaves a window.[ -n "$PROD_ENV" ] || exit 1catches the empty-secret failure from lesson 17 before it truncates the file to nothing.printf '%s\n'rather thanecho, becauseechomangles values beginning with-and interprets backslashes on some shells.
The server stores no secrets
Notice what just happened: the configuration was written to the server on every deploy, from GitHub Secrets.
That has a consequence people trip over. The env files on the server are disposable pipeline artifacts. Editing one by hand to fix something is pointless — the next deploy overwrites it. The change belongs in the Secret.
It has a matching cost, which the repository is honest about: GitHub Secrets are write-only. You
cannot read one back. Losing the master copy means regenerating every value, which is why
deploy/backup.sh captures the env files together with the database
dumps — a dump alone restores to a system nobody can log into, because the JWT signing key lives in
.env.
Deploy to your fake VPS
Real practice, no cloud spend. Start the box:
cd learn-devops/lab-vps
./up.sh
Add its private key as a repository secret. Print the whole file, headers included:
cat learn-devops/lab-vps/keys/id_ed25519
A key pasted without -----BEGIN OPENSSH PRIVATE KEY----- fails with an unhelpful message.
The obstacle: a GitHub runner cannot reach your laptop. Two honest options.
Option A — run the same box inside the runner. The runner is a Linux machine with Docker, so
start the lab image there and SSH to localhost:
- name: Start a lab VPS inside the runner
run: |
docker build -t lab-vps ./learn-devops/lab-vps
ssh-keygen -t ed25519 -f /tmp/labkey -N ''
docker run -d --name lab-vps --privileged -p 2222:22 \
-v /tmp/labkey.pub:/opt/lab/authorized_key.pub:ro lab-vps
Everything after that is identical to a real deploy. Because the box is destroyed with the runner, the whole deploy-and-rollback cycle happens within one run — which is exactly what lesson 19 does.
Option B — a self-hosted runner on your laptop. Closer to reality, more setup. Worth knowing exists; not worth doing today.
Break it on purpose: no health gate
Predict: your deploy script pulls the new image and runs docker compose up -d, then the
workflow ends green. The new version crashes on startup. What does the pipeline report?
Success. up -d returns as soon as the container is created, which says nothing about whether
it works. You have a green pipeline and a broken site.
This is why every real deploy ends with a wait:
wait_healthy() {
local deadline=$(( $(date +%s) + HEALTH_TIMEOUT ))
while [ "$(date +%s)" -lt "$deadline" ]; do
case "$(docker inspect -f '{{.State.Health.Status}}' "$BACKEND")" in
healthy) return 0 ;;
unhealthy) break ;;
esac
sleep 5
done
docker logs --tail 100 "$BACKEND"
return 1
}
The health status comes from the container healthcheck you wrote in lesson 08 — this is where that pays off. Note it dumps logs before returning failure, for the reason in lesson 12: the evidence may not survive.
And when the gate fails, the script rolls back and still exits non-zero. A deploy that had to roll back did not succeed, so the run must go red even though the site is fine. Green means "the new version is live", not "the site is up".
Where this shows up in MotorPH
- Four workflows SSH into the VPS, all with
appleboy/[email protected]. - The staging deploy job declares
environment: Productionto see the secrets — lesson 17. - The production job never builds. It promotes the exact tag staging proved.
- The VPS checkout tracks
main(git pull --ff-only origin main) even on a staging push, so a staging deploy ships that branch's images against main's configuration. The workflow says so in a comment — a deliberate trade, written down.
Recap
- Deploying from CI is SSH plus a script; the step is red when the script exits non-zero.
- Never interpolate a secret's content into a script with
${{ }}. Pass it viaenvs:so content and code stay separate. umask 077before writing, check for empty before truncating.- The server stores no secrets — they are rewritten each deploy, so hand edits are pointless and backups must include them.
up -dis not a health gate. Wait for healthy, roll back on failure, and go red anyway.
Next: 19 — The capstone.