Fix "Permission denied (publickey)" in Django CI Deploys
Fix Permission denied (publickey) and Host key verification failed when GitHub Actions, GitLab CI, or another pipeline deploys Django over SSH: load the private key correctly, add known_hosts, fix authorized_keys permissions, and check the user.
Problem statement
The deploy job reaches its SSH step and fails:
deploy@203.0.113.10: Permission denied (publickey).
Error: Process completed with exit code 255.
or, before authentication even starts:
Host key verification failed.
You can SSH to the server from your laptop, so the server is up. The pipeline is using a different key, user, or host identity than you think. "Permission denied (publickey)" means the server did not accept any key the client offered. "Host key verification failed" means the client does not trust the server's identity because it is not in known_hosts.
- Store the private key in a CI secret with its header, footer, and trailing newline intact; add the matching public key to
~deploy/.ssh/authorized_keyson the server. - Load it with
webfactory/ssh-agent, or write it to a file withchmod 600. - Add the server's host key to
known_hostsfrom a pinned value (ssh-keyscanoutput saved as a secret), not by disabling checks. - Check permissions on the server:
~/.sshis 700 andauthorized_keysis 600, owned by the deploy user. - Debug with
ssh -vvvin the job and the server'sjournalctl -u ssh.
Step-by-step solution
1. Create a dedicated deploy key
On your machine:
ssh-keygen -t ed25519 -C "ci-deploy@myapp" -f ./ci_deploy -N ""
Add the public half to the server:
ssh-copy-id -i ./ci_deploy.pub deploy@203.0.113.10
# or append ci_deploy.pub to /home/deploy/.ssh/authorized_keys
Store the full contents of ci_deploy (the private key) as a CI secret, for example DEPLOY_SSH_KEY, then delete the local file or keep it in a password manager.
2. Load the key in the job
GitHub Actions:
- uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}
- name: Trust the server
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
- run: ssh deploy@203.0.113.10 'cd /srv/app && ./deploy.sh'
Without the action, write the file yourself:
install -m 600 /dev/null ~/.ssh/id_ed25519
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519
Keys pasted into secrets lose their final newline or get extra spaces surprisingly often; printf '%s\n' restores the newline. OpenSSH rejects private key files readable by others ("UNPROTECTED PRIVATE KEY FILE").
3. Pin the host key instead of disabling checks
ssh-keyscan -t ed25519 203.0.113.10
Verify the fingerprint against the server (ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub on the server), then save the ssh-keyscan line as a secret such as DEPLOY_KNOWN_HOSTS. Running ssh-keyscan inside every job, or StrictHostKeyChecking=no, accepts any server claiming that IP, which defeats the point of SSH host verification.
4. Check the server side
sudo ls -ld /home/deploy /home/deploy/.ssh
sudo ls -l /home/deploy/.ssh/authorized_keys
sudo journalctl -u ssh --since "-10min" | tail -20
sshd ignores authorized_keys if the home directory, .ssh, or the file is writable by group or others, and logs Authentication refused: bad ownership or modes. Fix with:
sudo chown -R deploy:deploy /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keys
Also check AllowUsers in sshd_config, and that the job connects as deploy, not root or ubuntu.
5. Debug verbosely
Add a temporary step:
ssh -vvv -o BatchMode=yes deploy@203.0.113.10 true
Look for Offering public key: ... ED25519 SHA256:... (which key is offered), and Server accepts key or Authentications that can continue: publickey. Compare the offered fingerprint with ssh-keygen -lf ci_deploy.pub.
Verification check: the CI step ssh -o BatchMode=yes deploy@host 'echo ok' prints ok, and the server's auth log shows Accepted publickey for deploy.
Explanation
SSH authentication runs in two directions. First the client verifies the server's host key against known_hosts, protecting against connecting to an impostor. Then the server checks whether one of the client's offered public keys appears in the target user's authorized_keys, and the client proves possession of the matching private key. CI runners start with an empty ~/.ssh, so neither the host key nor your personal key is available; both must be provided explicitly, and secrets storage often mangles the multi-line private key format along the way.
Edge cases / notes
- Passphrase-protected keys cannot be used non-interactively; use a key without a passphrase that is restricted to deploying.
- Restrict the key in
authorized_keyswithfrom="..."or a forcedcommand=that only runs the deploy script. - Bastion hosts need
ProxyJumpand the key accepted on both hosts. - Server rebuilds change the host key; update the pinned
known_hostssecret, after verifying the new fingerprint.
Next steps
- Build the full pipeline with Deploy Django With GitHub Actions CI/CD.
- For pulling private repos on the server, see Deploy Django From a Private Git Repo With Deploy Keys.
FAQ
Why does SSH work from my laptop but not from CI?
Your laptop offers your personal key and already trusts the host. CI has neither unless you configure them.
Is StrictHostKeyChecking=no acceptable?
It works but accepts any server at that address. Pin the host key in a secret instead.
Which key goes where?
The private key goes into the CI secret; the public key goes into authorized_keys on the server.
Why is my key rejected even though it is in authorized_keys?
Check permissions and ownership of .ssh and authorized_keys, and that the job logs in as that same user.