{"id":66,"date":"2026-08-01T12:24:21","date_gmt":"2026-08-01T09:24:21","guid":{"rendered":"https:\/\/john-nessime.com\/blog\/?p=66"},"modified":"2026-08-01T12:24:31","modified_gmt":"2026-08-01T09:24:31","slug":"github-actions-vps-deployment","status":"publish","type":"post","link":"https:\/\/john-nessime.com\/blog\/devops\/github-actions-vps-deployment\/","title":{"rendered":"Your Deploy Key Is a Root Shell: GitHub Actions VPS Deployment Over SSH, Done Carefully"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Stop and look at what you are about to build. A private SSH key that can log into your production server, pasted into a text box on github.com, handed to a container you do not control, which then runs code from repositories maintained by people you have never met.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That is not hypothetical caution. In March 2025 a widely used third-party action was compromised: the attacker got hold of a maintainer&#8217;s token, pushed a malicious commit, and then <em>retroactively repointed every version tag<\/em> at it. Repositories that referenced the action by tag, which is nearly all of them, picked up the payload automatically. It dumped the runner&#8217;s secrets into the build log. On public repositories, those logs are readable by anyone.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Nobody&#8217;s workflow file changed. Nobody clicked anything. The pipeline just quietly handed out its credentials one Friday.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A <strong>GitHub Actions VPS deployment<\/strong> is fundamentally a credential handoff, and most guides on the topic skip straight to the YAML. This post covers the YAML too, but the useful part is what surrounds it: how to narrow the key so it cannot do much, how to verify you are talking to the right server, how to stop an unreviewed commit reaching production, and which of the failures here are silent.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Who can actually reach that key<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Worth being explicit, because it is a longer list than people expect:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Anyone with write access to the repository.<\/strong> A workflow file is code. Push a branch with a step that prints the key somewhere, trigger it, done. Write access to the repo is effectively access to the server.<\/li>\n<li><strong>Every action in the job.<\/strong> Secrets are exposed to the whole job, not just the step that references them. A compromised action anywhere in that job can read the environment.<\/li>\n<li><strong>Anyone who can influence what runs.<\/strong> This is why <code>pull_request_target<\/code> combined with checking out the pull request&#8217;s own code is such a well-known foot-gun: it runs the fork&#8217;s code with the base repository&#8217;s secrets in scope.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The goal is not to make the key unreachable. It is to make a stolen key not worth much.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Decide how the runner authenticates<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Three options, and the right one depends on what you already run.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>A dedicated SSH key in repository secrets.<\/strong> The default. Simple, works everywhere, and it is a long-lived credential sitting in a place several people can reach. Acceptable when the key is narrowed properly, which is most of the rest of this post.<\/li>\n<li><strong>An overlay network.<\/strong> Put the runner and the server on a private network with Tailscale or WireGuard for the duration of the job, using an ephemeral auth key, and close SSH to the public internet entirely. More moving parts, and it removes port 22 from the internet rather than defending it. If you already run an overlay for other reasons, this is clearly better.<\/li>\n<li><strong>A self-hosted runner on the server.<\/strong> No inbound SSH at all, since the runner polls outward. The trade-off is real and often understated: you have just installed a general-purpose code execution service on your production box, and for a public repository that is close to indefensible. On a private repository with a trusted team it is reasonable.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">One thing that sounds appealing and is not practical: allowlisting GitHub&#8217;s runner IP ranges at your firewall. The ranges are published through GitHub&#8217;s meta API, but they are enormous and they change, and every other GitHub customer&#8217;s job runs from inside them. It buys you very little for a lot of maintenance.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Narrow the key until it is boring<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Assume the key leaks. What can the holder do? Work backwards from there.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Give it its own user.<\/strong> A <code>deploy<\/code> account that owns the application directory and nothing else. Not your admin account, not root, and definitely not a key you also use from your laptop.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Restrict the key in <code>authorized_keys<\/code>.<\/strong> SSH lets you attach options to an individual key, and they apply regardless of what the client asks for:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># ~deploy\/.ssh\/authorized_keys\n#\n# restrict = no port forwarding, no agent forwarding, no X11,\n#            no PTY, no user rc file. Deny everything, then\n#            re-enable only what you need.\n# command  = ignore whatever the client asked to run and run\n#            this instead. The client's command is available\n#            to the script as $SSH_ORIGINAL_COMMAND.\n\nrestrict,command=\"\/usr\/local\/bin\/deploy-guard\" ssh-ed25519 AAAA...  deploy@github-actions<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The guard script decides what the key is allowed to do. Keep it short enough to read in one go:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#!\/usr\/bin\/env bash\n# \/usr\/local\/bin\/deploy-guard\nset -euo pipefail\n\ncase \"${SSH_ORIGINAL_COMMAND:-}\" in\n  # rsync sends its own server-side invocation. Match it loosely\n  # enough to work, tightly enough to pin the destination.\n  \"rsync --server \"*\" \/var\/www\/app\/\")\n    exec $SSH_ORIGINAL_COMMAND\n    ;;\n  \"deploy:reload\")\n    exec sudo \/bin\/systemctl reload nginx\n    ;;\n  *)\n    echo \"refused: $SSH_ORIGINAL_COMMAND\" &gt;&amp;2\n    exit 1\n    ;;\nesac<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Now a stolen key can write to one directory and reload one service. It cannot open a shell, cannot forward a port into your private network, and cannot read anything else on the box.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Scope the sudo entry too.<\/strong> The deploy user needs to reload a service, not run arbitrary commands as root:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># visudo -f \/etc\/sudoers.d\/deploy\n# One command, no password, no wildcards. A wildcard here\n# often turns back into a general-purpose root shell.\ndeploy ALL=(root) NOPASSWD: \/bin\/systemctl reload nginx<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Be honest about the cost: the guard script is one more thing to maintain, and the day you add a new deploy step it will refuse it and you will spend ten minutes confused. That is the trade you are making, and on anything handling customer data it is worth it. On a hobby project, a plain unrestricted <code>deploy<\/code> user with no sudo is a reasonable stopping point.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Verify the server, not just the key<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Almost every tutorial on this topic contains <code>StrictHostKeyChecking=no<\/code>, which turns off the check that stops you handing your credentials to whatever machine answered. It is there because the alternative is mildly annoying, and the alternative takes one command.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Run this once, from a machine you trust, and compare the\n# fingerprint against what the server reports locally.\nssh-keyscan -t ed25519 example.com<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Store that output as a repository variable, not a secret. It is public information, and keeping it visible means you can actually see it when it changes, which matters because it will change the first time you rebuild the server.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The workflow<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>name: Deploy\n\non:\n  push:\n    tags: ['v*']          # production ships from tags, not branches\n\n# This job reads code and talks to a server. It has no business\n# writing to the repository, so take that away.\npermissions:\n  contents: read\n\nconcurrency:\n  group: deploy-production\n  # Deliberately NOT cancel-in-progress. Killing a deploy halfway\n  # leaves the server in a state nobody designed.\n  cancel-in-progress: false\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    timeout-minutes: 15\n    environment:\n      name: production      # scoped secrets and required reviewers live here\n      url: https:\/\/example.com\n\n    steps:\n      # Pin every action to a full 40-character commit SHA, with the\n      # version in a trailing comment so Dependabot can still bump it.\n      # Get the SHA from the action's releases page.\n      - uses: actions\/checkout@PUT_THE_FULL_COMMIT_SHA_HERE  # pinned\n\n      - name: Build\n        run: |\n          npm ci\n          npm run build\n\n      - name: Configure SSH\n        # Pass secrets through env, never interpolate ${{ }} directly\n        # into a run block. Interpolation happens before the shell\n        # sees the script, so a value with the wrong characters\n        # either breaks the script or becomes part of it.\n        env:\n          DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}\n          KNOWN_HOSTS: ${{ vars.DEPLOY_KNOWN_HOSTS }}\n        run: |\n          install -m 700 -d ~\/.ssh\n          printf '%sn' \"$DEPLOY_KEY\" &gt; ~\/.ssh\/id_ed25519\n          chmod 600 ~\/.ssh\/id_ed25519\n          printf '%sn' \"$KNOWN_HOSTS\" &gt; ~\/.ssh\/known_hosts\n\n      - name: Ship\n        run: |\n          rsync -az --delete --exclude-from=.deployignore \n            -e \"ssh -o BatchMode=yes\" \n            .\/dist\/ deploy@example.com:\/var\/www\/app\/\n\n      - name: Reload and verify\n        run: |\n          ssh -o BatchMode=yes deploy@example.com 'deploy:reload'\n          curl --fail --silent --show-error https:\/\/example.com\/healthz &gt; \/dev\/null<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three details in there worth calling out, because they are the ones people leave out.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>-o BatchMode=yes<\/code> stops SSH prompting for anything. Without it, a missing key or an unknown host produces a prompt that nobody is there to answer, and the job hangs until the timeout instead of failing in three seconds.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>--delete<\/code> is what makes the target match the source, which is the point of a deploy, and also what will erase your uploads directory if <code>.deployignore<\/code> is wrong. Run it once with <code>--dry-run<\/code> against staging and read the list before you point it at production.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The final <code>curl --fail<\/code> is the difference between &#8220;the files copied&#8221; and &#8220;the site works&#8221;. Files landing on disk is not a successful deploy, and a job that goes green while the site returns 502 is worse than one that fails.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Third-party actions<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">There are convenient actions that wrap SSH deployment, and they save maybe eight lines. In the one job on your account that holds a production credential, eight lines is a bad trade for another maintainer in your trust chain. The workflow above uses plain <code>ssh<\/code> and <code>rsync<\/code> for that reason.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Where you do use third-party actions, pin them to a full commit SHA. A tag is a pointer and pointers can be moved, which is precisely what happened in the incident at the top of this post. A SHA is content-addressed: it cannot be repointed at different code.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two things make this sustainable rather than miserable. Dependabot understands SHA pins with a version comment and will open pull requests to bump them. And you can restrict which actions are allowed to run at all, in the repository or organisation settings, which is worth doing once and forgetting about.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Gate production properly<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>environment:<\/code> key in that workflow is doing more work than it appears to. Environments give you three things you cannot get from repository secrets alone:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Scoped secrets.<\/strong> The production key is only available to jobs that declare the production environment. A branch that adds a job without it gets nothing.<\/li>\n<li><strong>Required reviewers.<\/strong> The job pauses and waits for a named human to approve it. This is the single control that turns &#8220;anyone with write access can deploy&#8221; into &#8220;anyone with write access can request a deploy&#8221;.<\/li>\n<li><strong>Deployment branch and tag rules.<\/strong> Restrict the environment so only protected tags or a specific branch can use it, so a feature branch cannot reach production even with the right job definition.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Deploying from tags rather than branch pushes is worth the small friction. It makes a release a deliberate act, and it gives you an obvious rollback: redeploy the previous tag.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For anything with real traffic, put the files somewhere new and switch atomically rather than rsyncing over a live directory. Copy into a timestamped release directory, then move a symlink. The site changes version instantly instead of spending several seconds as a mix of old and new files, and rollback becomes a symlink change rather than a redeploy.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Troubleshooting<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Permission denied (publickey)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The most common cause by far is a missing trailing newline. Private keys must end with one, and pasting into a web form frequently drops it. That is why the workflow uses <code>printf '%sn'<\/code> rather than <code>echo<\/code>: it guarantees the newline regardless of what got stored.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">After that, check permissions on the server: <code>~\/.ssh<\/code> must be 700 and <code>authorized_keys<\/code> 600, owned by the deploy user, and the home directory must not be group-writable. Add <code>-vvv<\/code> to the ssh command to see how far the handshake gets, and read <code>journalctl -u ssh<\/code> on the server for the reason, since the client is deliberately vague.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Host key verification failed<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Either the <code>known_hosts<\/code> variable is empty, or the server&#8217;s host key genuinely changed. If you rebuilt the server, regenerate it with <code>ssh-keyscan<\/code> and update the variable. If you did not rebuild the server, stop and find out why the key changed before you update anything.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The job hangs until the timeout<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">SSH is waiting on a prompt nobody will answer. Add <code>-o BatchMode=yes<\/code> and it fails immediately with a readable reason instead.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">rsync works from my laptop, fails from the runner<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">If you set a forced command, it is rejecting the invocation. The refusal message goes to stderr on the server side, so add a log line to the guard script that records <code>$SSH_ORIGINAL_COMMAND<\/code>, run the job once, and read what rsync actually sent.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Works on push, fails on pull requests<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Working as intended. Secrets are not exposed to workflows triggered by pull requests from forks. Do not reach for <code>pull_request_target<\/code> to work around it. Split the workflow so that pull requests build and test without secrets, and only pushes or tags deploy.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">A secret appeared in the logs<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Masking matches the exact stored string. Transform it in any way and the mask stops working: base64-encoded, JSON-escaped, split across lines, or with whitespace trimmed. Do not treat masking as a control. Rotate the key, delete the run logs, and remove whatever produced the output.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Common mistakes<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Using an existing personal SSH key instead of generating a dedicated one for the runner.<\/li>\n<li>Giving the deploy user full sudo, or a sudo rule with a wildcard in it.<\/li>\n<li>Disabling host key checking with <code>StrictHostKeyChecking=no<\/code>.<\/li>\n<li>Referencing third-party actions by tag in the job that holds your production key.<\/li>\n<li>Interpolating <code>${{ secrets.X }}<\/code> directly into a <code>run:<\/code> script instead of passing it through <code>env:<\/code>.<\/li>\n<li>Leaving default write permissions on a job that only needs to read.<\/li>\n<li>Using <code>cancel-in-progress: true<\/code> on a deploy, so a second push interrupts the first mid-copy.<\/li>\n<li>Running <code>rsync --delete<\/code> at production without testing the exclude list.<\/li>\n<li>Treating &#8220;the files copied&#8221; as a successful deploy, with no health check.<\/li>\n<li>Deploying straight from branch pushes with no approval step.<\/li>\n<li>Relying on log masking to keep a leaked secret out of the output.<\/li>\n<li>Never rotating the deploy key, including after someone leaves the team.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Best practices<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Dedicated deploy user, dedicated key, used for nothing else.<\/li>\n<li>Restrict the key in <code>authorized_keys<\/code> with <code>restrict<\/code> and a forced command.<\/li>\n<li>Scope sudo to the exact commands the deploy needs.<\/li>\n<li>Pin every action to a full commit SHA and let Dependabot bump them.<\/li>\n<li>Keep the deploy job small: fewer steps means fewer things that can read the secret.<\/li>\n<li>Set <code>permissions:<\/code> explicitly, defaulting to read-only.<\/li>\n<li>Verify host keys from a stored <code>known_hosts<\/code> value.<\/li>\n<li>Use environments with required reviewers and deployment rules for production.<\/li>\n<li>Deploy from tags, and make rollback a redeploy of the previous tag or a symlink swap.<\/li>\n<li>End every deploy with a real HTTP request against the public URL.<\/li>\n<li>Rotate the deploy key on a schedule and whenever someone leaves.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">FAQ<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Is it safe to store an SSH private key in GitHub secrets?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Safe enough, if the key is worth little on its own. Secrets are encrypted at rest and masked in logs, but they are readable by any job that references them and by anyone who can modify a workflow. Assume it can leak and make sure a leaked key only grants write access to one directory and one service reload.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Should I use a ready-made SSH deploy action?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">They work and they are convenient. My preference is plain <code>ssh<\/code> and <code>rsync<\/code> in the job that touches production, because it removes a maintainer from the trust chain for very little added effort. If you do use one, pin it to a SHA like anything else.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Can I use OIDC instead of a stored key?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Not directly against sshd, which has no concept of an OIDC token. OIDC removes long-lived secrets when the target is a cloud provider that can verify GitHub&#8217;s token. To get the same benefit for a plain VPS you need something in between that trades the token for short-lived access: a secrets manager, a bastion that supports it, or an overlay network with ephemeral auth keys.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Should I allowlist GitHub&#8217;s IP ranges on my firewall?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Generally not worth it. The published ranges are large and change over time, so you are signing up for ongoing maintenance, and every other GitHub customer&#8217;s runners share those ranges. Narrowing what the key can do achieves more.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Self-hosted runner or hosted runner?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Hosted for public repositories, without exception, because a self-hosted runner will execute code from pull requests. Self-hosted is reasonable on a private repository with a trusted team, and it removes inbound SSH entirely. Just be clear that you have moved the risk rather than removed it.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How do I roll back a bad deploy?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Redeploy the previous tag, or swap the symlink back if you use atomic releases. The part that does not roll back is the database, so if a release ran a migration, know what it did before you revert the files. Files going backwards while the schema stays forwards is often worse than the bug you were fixing.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How often should the deploy key be rotated?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">On a schedule you will actually keep, and immediately whenever someone with repository access leaves or a run log looks suspicious. Rotation is two steps, adding the new public key to <code>authorized_keys<\/code> and updating the secret, so there is no good reason for it to be a project.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">The one thing to remember<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The YAML is the easy half. A working GitHub Actions VPS deployment takes an afternoon; the part that decides whether it is a good idea is what that key can do once it is out of your hands, and it will eventually be out of your hands.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So build it assuming the key leaks. Dedicated user, forced command, scoped sudo, pinned actions, an approval gate on production. Then the worst case is an inconvenient rotation rather than an incident, and you will not be reading a build log at midnight trying to work out what a stranger&#8217;s code did with your credentials.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Want this built or reviewed?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Most deploy pipelines I get asked to look at work fine and hand out far more access than they need. Work I take on:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Building a GitHub Actions deploy to a VPS end to end: build, ship, reload, health check, rollback path.<\/li>\n<li>Auditing an existing pipeline for what a leaked runner secret would actually grant, and closing the gap.<\/li>\n<li>Locking down the server side: deploy user, forced commands, scoped sudo, atomic release directories.<\/li>\n<li>Moving SSH off the public internet onto an overlay network, or setting up a self-hosted runner safely.<\/li>\n<li>Pinning and auditing third-party actions, plus repository settings that limit what can run.<\/li>\n<li>Environments, required reviewers and tag-based releases for teams that currently deploy on every push to main.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Send me your workflow file and the output of <code>ssh deploy@yourhost 'id'<\/code>, and I will tell you what a stolen key would get.<\/p>\n\n\n\n<div class=\"wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex\">\n<div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\" href=\"https:\/\/www.upwork.com\/freelancers\/~01f15a912ad84a6620\" target=\"_blank\" rel=\"noreferrer noopener\">Work with me on Upwork<\/a><\/div>\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>A deploy key in GitHub secrets is a shell on your production server, handed to a container you don&#8217;t control, running code from maintainers you&#8217;ve never met. Here&#8217;s how to build the pipeline so a leaked key isn&#8217;t worth much: forced commands, scoped sudo, pinned actions and a real approval gate.<\/p>\n","protected":false},"author":1,"featured_media":67,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[24,52,30],"tags":[94,120,95,133,3,132,101,21,6,141,10,135,140,147,72,4,138,112],"class_list":["post-66","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devops","category-technical-guides","category-web-security","tag-automation","tag-bash","tag-ci-cd","tag-deployment","tag-devops","tag-git","tag-github-actions","tag-infrastructure","tag-linux","tag-openssh","tag-production","tag-rsync","tag-ssh","tag-supply-chain-security","tag-sysadmin","tag-troubleshooting","tag-vps","tag-web-security","entry","has-media"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.2 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>GitHub Actions VPS Deployment: SSH Without the Risk<\/title>\n<meta name=\"description\" content=\"GitHub Actions VPS deployment over SSH, done safely: scoping the deploy key, pinning actions to a SHA, host key checks, and gating production.\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/john-nessime.com\/blog\/devops\/github-actions-vps-deployment\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"GitHub Actions VPS Deployment: SSH Without the Risk\" \/>\n<meta property=\"og:description\" content=\"GitHub Actions VPS deployment over SSH, done safely: scoping the deploy key, pinning actions to a SHA, host key checks, and gating production.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/john-nessime.com\/blog\/devops\/github-actions-vps-deployment\/\" \/>\n<meta property=\"og:site_name\" content=\"John Nessime\" \/>\n<meta property=\"article:published_time\" content=\"2026-08-01T09:24:21+00:00\" \/>\n<meta property=\"article:modified_time\" content=\"2026-08-01T09:24:31+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/github-actions-vps-deployment.png\" \/>\n\t<meta property=\"og:image:width\" content=\"1200\" \/>\n\t<meta property=\"og:image:height\" content=\"800\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/png\" \/>\n<meta name=\"author\" content=\"John Nessime\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"John Nessime\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"12 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/github-actions-vps-deployment\\\/#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/github-actions-vps-deployment\\\/\"},\"author\":{\"name\":\"John Nessime\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\"},\"headline\":\"Your Deploy Key Is a Root Shell: GitHub Actions VPS Deployment Over SSH, Done Carefully\",\"datePublished\":\"2026-08-01T09:24:21+00:00\",\"dateModified\":\"2026-08-01T09:24:31+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/github-actions-vps-deployment\\\/\"},\"wordCount\":2643,\"commentCount\":0,\"publisher\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\"},\"image\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/github-actions-vps-deployment\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/08\\\/github-actions-vps-deployment.png\",\"keywords\":[\"Automation\",\"Bash\",\"CI\\\/CD\",\"Deployment\",\"DevOps\",\"Git\",\"GitHub Actions\",\"Infrastructure\",\"Linux\",\"OpenSSH\",\"Production\",\"rsync\",\"SSH\",\"Supply Chain Security\",\"Sysadmin\",\"Troubleshooting\",\"VPS\",\"Web Security\"],\"articleSection\":[\"DevOps\",\"Technical Guides\",\"Web Security\"],\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"CommentAction\",\"name\":\"Comment\",\"target\":[\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/github-actions-vps-deployment\\\/#respond\"]}]},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/github-actions-vps-deployment\\\/\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/github-actions-vps-deployment\\\/\",\"name\":\"GitHub Actions VPS Deployment: SSH Without the Risk\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/github-actions-vps-deployment\\\/#primaryimage\"},\"image\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/github-actions-vps-deployment\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/08\\\/github-actions-vps-deployment.png\",\"datePublished\":\"2026-08-01T09:24:21+00:00\",\"dateModified\":\"2026-08-01T09:24:31+00:00\",\"description\":\"GitHub Actions VPS deployment over SSH, done safely: scoping the deploy key, pinning actions to a SHA, host key checks, and gating production.\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/github-actions-vps-deployment\\\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/github-actions-vps-deployment\\\/\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/github-actions-vps-deployment\\\/#primaryimage\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/08\\\/github-actions-vps-deployment.png\",\"contentUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/08\\\/github-actions-vps-deployment.png\",\"width\":1200,\"height\":800,\"caption\":\"Trust boundary diagram of a GitHub Actions VPS deployment, showing a deploy key crossing from GitHub through an ephemeral runner where every step and third-party action can read it, into a restricted deploy user on the server.\"},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/github-actions-vps-deployment\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Your Deploy Key Is a Root Shell: GitHub Actions VPS Deployment Over SSH, Done Carefully\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#website\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/\",\"name\":\"John Nessime\",\"description\":\"Cloud, DevOps, Data &amp; AI \u2014 Built, Tested, Explained\",\"publisher\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":[\"Person\",\"Organization\"],\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\",\"name\":\"John Nessime\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\",\"contentUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\",\"width\":512,\"height\":512,\"caption\":\"John Nessime\"},\"logo\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\"},\"description\":\"AWS Certified Solutions Architect helping businesses build reliable cloud, data, reporting, and automation solutions. I help startups, agencies, and growing businesses replace manual processes and disconnected data with practical AWS architectures, clean data pipelines, useful dashboards, and maintainable automation.\",\"sameAs\":[\"https:\\\/\\\/john-nessime.com\\\/blog\",\"https:\\\/\\\/www.linkedin.com\\\/in\\\/john-m-nessime\"],\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/author\\\/johnnessime\\\/\"}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"GitHub Actions VPS Deployment: SSH Without the Risk","description":"GitHub Actions VPS deployment over SSH, done safely: scoping the deploy key, pinning actions to a SHA, host key checks, and gating production.","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/john-nessime.com\/blog\/devops\/github-actions-vps-deployment\/","og_locale":"en_US","og_type":"article","og_title":"GitHub Actions VPS Deployment: SSH Without the Risk","og_description":"GitHub Actions VPS deployment over SSH, done safely: scoping the deploy key, pinning actions to a SHA, host key checks, and gating production.","og_url":"https:\/\/john-nessime.com\/blog\/devops\/github-actions-vps-deployment\/","og_site_name":"John Nessime","article_published_time":"2026-08-01T09:24:21+00:00","article_modified_time":"2026-08-01T09:24:31+00:00","og_image":[{"width":1200,"height":800,"url":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/github-actions-vps-deployment.png","type":"image\/png"}],"author":"John Nessime","twitter_card":"summary_large_image","twitter_misc":{"Written by":"John Nessime","Est. reading time":"12 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/john-nessime.com\/blog\/devops\/github-actions-vps-deployment\/#article","isPartOf":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/github-actions-vps-deployment\/"},"author":{"name":"John Nessime","@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105"},"headline":"Your Deploy Key Is a Root Shell: GitHub Actions VPS Deployment Over SSH, Done Carefully","datePublished":"2026-08-01T09:24:21+00:00","dateModified":"2026-08-01T09:24:31+00:00","mainEntityOfPage":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/github-actions-vps-deployment\/"},"wordCount":2643,"commentCount":0,"publisher":{"@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105"},"image":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/github-actions-vps-deployment\/#primaryimage"},"thumbnailUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/github-actions-vps-deployment.png","keywords":["Automation","Bash","CI\/CD","Deployment","DevOps","Git","GitHub Actions","Infrastructure","Linux","OpenSSH","Production","rsync","SSH","Supply Chain Security","Sysadmin","Troubleshooting","VPS","Web Security"],"articleSection":["DevOps","Technical Guides","Web Security"],"inLanguage":"en-US","potentialAction":[{"@type":"CommentAction","name":"Comment","target":["https:\/\/john-nessime.com\/blog\/devops\/github-actions-vps-deployment\/#respond"]}]},{"@type":"WebPage","@id":"https:\/\/john-nessime.com\/blog\/devops\/github-actions-vps-deployment\/","url":"https:\/\/john-nessime.com\/blog\/devops\/github-actions-vps-deployment\/","name":"GitHub Actions VPS Deployment: SSH Without the Risk","isPartOf":{"@id":"https:\/\/john-nessime.com\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/github-actions-vps-deployment\/#primaryimage"},"image":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/github-actions-vps-deployment\/#primaryimage"},"thumbnailUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/github-actions-vps-deployment.png","datePublished":"2026-08-01T09:24:21+00:00","dateModified":"2026-08-01T09:24:31+00:00","description":"GitHub Actions VPS deployment over SSH, done safely: scoping the deploy key, pinning actions to a SHA, host key checks, and gating production.","breadcrumb":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/github-actions-vps-deployment\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/john-nessime.com\/blog\/devops\/github-actions-vps-deployment\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/john-nessime.com\/blog\/devops\/github-actions-vps-deployment\/#primaryimage","url":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/github-actions-vps-deployment.png","contentUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/08\/github-actions-vps-deployment.png","width":1200,"height":800,"caption":"Trust boundary diagram of a GitHub Actions VPS deployment, showing a deploy key crossing from GitHub through an ephemeral runner where every step and third-party action can read it, into a restricted deploy user on the server."},{"@type":"BreadcrumbList","@id":"https:\/\/john-nessime.com\/blog\/devops\/github-actions-vps-deployment\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/john-nessime.com\/blog\/"},{"@type":"ListItem","position":2,"name":"Your Deploy Key Is a Root Shell: GitHub Actions VPS Deployment Over SSH, Done Carefully"}]},{"@type":"WebSite","@id":"https:\/\/john-nessime.com\/blog\/#website","url":"https:\/\/john-nessime.com\/blog\/","name":"John Nessime","description":"Cloud, DevOps, Data &amp; AI \u2014 Built, Tested, Explained","publisher":{"@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/john-nessime.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":["Person","Organization"],"@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105","name":"John Nessime","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png","url":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png","contentUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png","width":512,"height":512,"caption":"John Nessime"},"logo":{"@id":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png"},"description":"AWS Certified Solutions Architect helping businesses build reliable cloud, data, reporting, and automation solutions. I help startups, agencies, and growing businesses replace manual processes and disconnected data with practical AWS architectures, clean data pipelines, useful dashboards, and maintainable automation.","sameAs":["https:\/\/john-nessime.com\/blog","https:\/\/www.linkedin.com\/in\/john-m-nessime"],"url":"https:\/\/john-nessime.com\/blog\/author\/johnnessime\/"}]}},"_links":{"self":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts\/66","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/comments?post=66"}],"version-history":[{"count":1,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts\/66\/revisions"}],"predecessor-version":[{"id":71,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts\/66\/revisions\/71"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/media\/67"}],"wp:attachment":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/media?parent=66"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/categories?post=66"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/tags?post=66"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}