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.

Two separate SSH checksClient checks hostknown_hosts entryClient offers keyfrom CI secretServer checks keyauthorized_keysLogin or refusalpublickey deniedHost key errors: you distrust the server. Publickey errors: the server distrusts you.
  1. 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_keys on the server.
  2. Load it with webfactory/ssh-agent, or write it to a file with chmod 600.
  3. Add the server's host key to known_hosts from a pinned value (ssh-keyscan output saved as a secret), not by disabling checks.
  4. Check permissions on the server: ~/.ssh is 700 and authorized_keys is 600, owned by the deploy user.
  5. Debug with ssh -vvv in the job and the server's journalctl -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").

one key pair, two placesCI secret: private key✓full -----BEGIN ... END----- block✓trailing newline preserved✓written with mode 600 or via ssh-agentServer: public key✓one line in authorized_keys✓~/.ssh 700, file 600, owned by deploy✓for the user the job logs in asMost publickey failures are a mismatch between these two halvesdjango-deployment.com
Generate a separate key for CI so it can be revoked without touching personal keys.

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.

error to causeError or log lineCause and fixHost key verification failedno known_hosts entry;add pinned host keyPermission denied (publickey)key not in authorized_keysfor that userinvalid format / libcrypto errorsecret mangled; restorenewline and headerbad ownership or modes (server log)chmod 700 .ssh, 600 authorized_keysworks locally, not in CIyour agent offers anotherkey; test with -issh -vvv in the job shows exactly which key was offereddjango-deployment.com
Check the server's auth log too; it states why a key was refused.

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_keys with from="..." or a forced command= that only runs the deploy script.
  • Bastion hosts need ProxyJump and the key accepted on both hosts.
  • Server rebuilds change the host key; update the pinned known_hosts secret, after verifying the new fingerprint.

Next steps

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.

Keep going