github-actions-ssh-deploy
Skill0 starsChanged 7 days agoMITView on GitHub
Use when adding or debugging push-to-deploy over SSH in Actions.
- Reads credentials
In this repository6 other files it gives its agents.
The file
We split this skill into the rules it gives an agent
4 rules. About 0 are common, 2 are distinctive, and 2 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
2
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 96
Bootstrap chicken-and-egg: If scripts/deploy.sh lives on the branch being deployed and the server clone sits on an older branch, the first …
- Distinctive: line 113
Prefer key-based auth with a dedicated deploy user over source-IP rules.
- Distinctive: line 122
Commit a read-only workflow on a scratch branch — triggered only by pushes to that branch, never by the deploy branch — run it, read the …
- Very specific: line 186
Keep the previous release reachable before deploying: tag the commit, or run the stack with an explicit image tag rather than latest.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Push-to-deploy over SSH from GitHub Actions
When to Use
- "Push to
<branch>must deploy to<host>", where the host is reachable by SSH from a GitHub runner but not (conveniently) from your machine. - A deploy workflow fails and the log gives nothing useful.
- You need to know whether a deploy actually shipped, not whether the job went green.
The workflow runs on GitHub's Linux runners, so authoring it from Windows is fine; only the local pre-flight checks need care.
The pattern
.github/workflows/deploy-<env>.yml:
name: Deploy to <env>
on:
push:
branches: [<branch>]
concurrency:
group: deploy-<env>
cancel-in-progress: false # never cancel a deploy mid-flight
jobs:
deploy:
runs-on: ubuntu-latest
environment: <env> # gates on environment protection rules
steps:
- uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script_stop: true
script: |
set -euo pipefail
cd <deploy-dir>
git fetch origin <branch>
git checkout <branch>
git pull --ff-only origin <branch>
./scripts/deploy.sh <env>Required repository secrets: DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_KEY (the private
key, PEM, with the public half in the host's ~/.ssh/authorized_keys). Set them with
gh secret set DEPLOY_HOST --body "<host>", or paste in the repository settings UI.
Design rules that make the difference between a deploy that works and one that works most of the time:
git fetch+checkout+pull --ff-onlyhappen in the workflow, before the script is invoked (see the bootstrap pitfall below).--ff-onlyfails loudly instead of creating a merge commit on a server.- The deploy directory is per environment (
<base>/<env>), and the branch checkout is asserted:git rev-parse --abbrev-ref HEADmust equal the expected branch. scripts/deploy.shin the repo does the rest:set -euo pipefail,--env-file, an explicit compose project name, a health-check poll loop that exits 1 on failure, and cleanup.- One concurrency group per environment so two pushes cannot interleave.
Skeleton scripts/deploy.sh:
#!/usr/bin/env bash
set -euo pipefail
env="${1:?usage: deploy.sh <env>}"
cd "$(dirname "$0")/.."
docker compose -p "<service>-$env" --env-file ".env.$env" up -d --build
for i in $(seq 1 30); do
if curl -fsS "http://127.0.0.1:${PORT:?}/health" >/dev/null; then
echo "Deployed commit: $(git rev-parse HEAD)"; exit 0
fi
sleep 2
done
echo "health check failed after 60s" >&2
docker compose -p "<service>-$env" logs --tail=100 >&2
exit 1Pitfalls that actually bite
- Bootstrap chicken-and-egg. If
scripts/deploy.shlives on the branch being deployed and the server clone sits on an older branch, the first run dies with./scripts/deploy.sh: No such file or directory(exit 127). Fix it by checking out the branch in the workflow first — never by copying the script to the server. - Directory-derived compose project names collide. Running
docker compose up -dfrom<base>/<service-a>/devand<base>/<service-b>/devgives both the project namedev.docker compose -p dev down --remove-orphansfrom one then destroys the other's containers. Always pass-p <service>-<env>. - Volume identity follows the project name (
<project>_<volume>). Renaming the project gives the new stack an empty database; either reseed or copy the old volume on purpose. - Secret masking mangles log lines. GitHub replaces every occurrence of a secret value with
***, so a secret whose value isdeploymakes the word "deploy" disappear from logs — paths become./***.sh, and a config line can vanish entirely. Confusing, not broken: read the raw log (gh run view <id> --log) and expect the substitution. script_stopdefaults to false in olderssh-actionversions, so a failing command does not fail the job. Set it explicitly, and alsoset -euo pipefailinside the script block.- The runner's public IP is not stable, so IP allowlists on the host break intermittently. Prefer key-based auth with a dedicated deploy user over source-IP rules.
appleboy/ssh-actionneeds the key in its raw form, including newlines. A key pasted into a GitHub secret usually survives fine; a key stored base64-encoded does not — decode it in the workflow if that is how it is stored.- Permissions drift:
chmod +xapplied on the server but not committed means the file arrives as100644.git ls-files -s scripts/deploy.shmust show mode100755.
Diagnosing a host you cannot SSH into
Commit a read-only workflow on a scratch branch — triggered only by pushes to that branch, never by the deploy branch — run it, read the log, then delete the branch. It executes as the deploy user and answers every question a shell session would:
on: { push: { branches: [chore/<service>-diag] } }
# appleboy/ssh-action with script_stop: false and:
# set +e
# ls -la <deploy-dir>; ls -la .env*
# sed -E 's/=.*/=<redacted>/' .env.* # keys without values
# grep -E '^(PORT|NODE_ENV|PUBLIC_URL)=' .env.*
# git -C <deploy-dir> rev-parse --abbrev-ref HEAD; git -C <deploy-dir> rev-parse HEAD
# git -C <deploy-dir> remote -v
# docker ps -a --format '{{.Names}} | {{.Image}} | {{.Ports}} | {{.Status}}'
# docker inspect --format '{{.Name}} project={{index .Config.Labels "com.docker.compose.project"}} wd={{index .Config.Labels "com.docker.compose.project.working_dir"}}' $(docker ps -aq)
# docker compose lsThis is what turns "deploy failed" into "the clone is on the wrong branch, .env.dev does not
exist, and two services share a compose project name". Delete the branch afterwards — the file
must not stay in the repository.
On Windows, sed -E 's/=.*/=<redacted>/' runs on the remote host (Linux), not locally, so it
is fine; do not try to reproduce it in PowerShell.
Verifying a deploy really worked
Four checks, in increasing strength:
gh run watch <id> --exit-status— the job's conclusion, then read the log, becausescript_stop: falseand swallowed failures both produce green runs.- The deploy script prints
Deployed commit: <sha>; assert it equals the pushed head:git ls-remote origin <branch> - Public smoke test:
curl -sS https://<host>/healthand the app root. - Prove the change actually shipped — capture before/after response headers:
A newcurl -sS -D - -o /dev/null https://<host>/ | grep -iE '^(etag|last-modified)'last-modifiedinside the deploy window is evidence; a200alone is not, because the previous version also returned 200.
Then confirm neighbours survived: docker ps --filter label=com.docker.compose.project=<other>
for any stack that shared the old project name.
Local pre-flight
bash -n scripts/deploy.sh(WSL or Git Bash on Windows).- Executable bit committed:
git ls-files -s scripts/deploy.sh→100755. - Lint the workflow without installing anything:
docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latestdocker run --rm -v "${PWD}:/repo" -w /repo rhysd/actionlint:latest - Diff against a workflow known to work in the same repository: identical except for the service path is the strongest evidence available without running it.
- Confirm Actions is enabled and permitted before debugging the YAML:
gh api repos/<owner>/<repo>/actions/permissions.
Rollback
Keep the previous release reachable before deploying: tag the commit, or run the stack with an
explicit image tag rather than latest. A rollback that depends on rebuilding the old code
after the new one failed is a rollback that happens during an incident.
git -C <deploy-dir> checkout <previous-sha>
./scripts/deploy.sh <env>Rehearse it once while nothing is broken.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

