GitHub Actions SSH Deploy: Permission Denied

Published · 10 views
GitHub Actions SSH Deploy: Permission Denied

GitHub Actions throws "Permission denied (publickey)" on a deploy step, and the first instinct is almost always wrong: people assume the key itself is bad and regenerate it, paste it into the secret again, and watch the exact same error come back five minutes later. I've burned a full afternoon on this exact loop on a client's deploy pipeline before I actually looked at what GitHub Actions does to a secret between storing it and using it in a shell step. The key was never the problem. How it got written to disk inside the runner was.

This error means one specific thing: the SSH server rejected the public key your runner presented, during the handshake, before it even checks the deploy script. It's not a network issue, not a firewall issue, not a "wrong host" issue — those fail differently. If you're seeing Permission denied (publickey), the server got a key and said no to it.

The actual mechanism, not just the error message

SSH key auth works by the server checking whether the private key you're presenting corresponds to a public key already sitting in ~/.ssh/authorized_keys for the user you're logging in as. Two things break this in a CI pipeline specifically, and almost everything else is a red herring:

  • The private key got corrupted on the way into the file the ssh command reads, usually by losing line breaks.
  • The public key half was never actually added to authorized_keys on the server, or it's there for the wrong user.

Here's the workflow step that looks completely reasonable and fails anyway:

- name: Deploy over SSH (the way that silently breaks)
  run: |
    echo "${{ secrets.SSH_PRIVATE_KEY }}" > id_rsa
    chmod 600 id_rsa
    ssh -o StrictHostKeyChecking=no -i id_rsa deploy@${{ secrets.DEPLOY_HOST }} "cd /var/www/app && ./deploy.sh"

This passes code review every time because it reads correctly. echo into a file, chmod 600, SSH with -i. The problem is that GitHub strips the trailing newline from a multi-line secret when it's expanded into a shell command, and echo on top of that can mangle how line breaks land depending on the runner's shell. A PEM-format private key is extremely sensitive to this — drop or shift even one newline and ssh either refuses to parse the key at all, or parses a key that no longer matches its public half, which the server correctly rejects with Permission denied (publickey). You get no indication that the key itself is malformed; SSH just treats it as "this isn't the right key" because, cryptographically, it isn't anymore.

The fix that actually holds up

Base64-encode the key before it ever becomes a GitHub secret, and decode it back to bytes inside the runner instead of trusting a shell variable to preserve a multi-line string exactly:

- name: Set up SSH deploy key (the version that actually works)
  run: |
    mkdir -p ~/.ssh
    echo "${{ secrets.SSH_PRIVATE_KEY_B64 }}" | base64 -d > ~/.ssh/deploy_key
    chmod 600 ~/.ssh/deploy_key
    ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts

- name: Deploy
  run: |
    ssh -i ~/.ssh/deploy_key deploy@${{ secrets.DEPLOY_HOST }} "cd /var/www/app && ./deploy.sh"

To store the secret correctly in the first place, run base64 -w0 deploy_key locally (or base64 -i deploy_key | tr -d '\n' on macOS) and paste that single unbroken line into the GitHub secret — not the raw PEM. Base64 output doesn't care about newlines being preserved on the way through GitHub's secret storage, which is exactly why this survives the trip and the raw PEM doesn't. I'd skip ssh-add/ssh-agent setups for a single deploy key in CI entirely — they're the right tool when a workflow needs to juggle multiple keys or forward agent access, but for one key doing one job, decoding it straight to a file with -i is less moving parts and fewer things to misconfigure.

Swapping StrictHostKeyChecking=no for a real ssh-keyscan into known_hosts isn't just cleaner, either — StrictHostKeyChecking=no accepts whatever host key is presented with no verification, which defeats the actual purpose of host key checking. It's a shortcut that happens to also work, which is a bad reason to use it in anything touching production infra.

If the key format wasn't the issue

Sometimes the base64 fix doesn't change anything, and that means the problem is on the server side, not the runner side:

  • The public key was added to the wrong user's authorized_keys — a deploy script that assumes deploy but the key was actually added under ubuntu or root will fail the exact same way.
  • authorized_keys or the .ssh directory has permissions SSH refuses to trust (SSH silently ignores authorized_keys if the directory is group-writable or world-writable — it won't tell you this in the client-side error).
  • The key type isn't allowed. Some hardened server configs disable RSA auth entirely via PubkeyAcceptedAlgorithms in sshd_config, so an RSA key gets rejected even though it's perfectly valid — switching to an ed25519 key sidesteps this.

Add -vvv to the ssh command in the workflow temporarily. The verbose output names the exact key it tried and whether the server even offered to check it — that single log line tells you in about ten seconds whether you're dealing with a corrupted key or a server-side authorized_keys problem, which is a lot faster than guessing.

Key takeaway

If you only fix one thing here, make it this: never trust a shell variable or echo to carry a multi-line private key through a CI pipeline unmodified — encode it to base64 before it becomes a secret, decode it back to a file in the runner, and the "regenerate the key and try again" loop stops being necessary.

#ci-cd #github-actions #devops #deployment #ssh
Aliyan Faisal

Written by

Aliyan Faisal

Full-stack developer and AI/LLM systems engineer. I build LLM integrations, RAG pipelines and automations, and the web apps and servers behind them.

0 Comments

No comments yet — be the first to share your thoughts.

Leave a comment

Never published.