SSH for Beginners: Log In to a Linux Server With a Key
SSH for beginners: make an Ed25519 key, log in to a Linux server without a password, open only ports 22, 80 and 443, and avoid the Cloudflare SSH trap.
By Matthews WongReviewed on
Key takeaway
SSH is the encrypted protocol you use to log in to a Linux server and run commands on it. With key authentication, your laptop keeps a private key and the server stores only the matching public key, so password login can be switched off. A firewall then leaves a web server just three doors open: 22, 80 and 443.
You will be able to
- Create an Ed25519 key pair and log in to a server with it, with password login switched off.
- Explain authorized_keys, known_hosts and the file permissions SSH insists on.
- Decide which ports a web server opens, and why SSH must not go through Cloudflare's proxy.
Step through the diagram
Press Play, or step with the arrows. Every step is written out below the diagram.
How an SSH key login works, and which doors stay open
ssh-keygen makes a key pair, the public half goes into authorized_keys, the server's fingerprint is saved in known_hosts, the private key proves who you are, ufw opens only 22, 80 and 443, and SSH must bypass Cloudflare's proxy.
Keys: ← → step · Home / End · K play or pause
Your laptop and a Linux VPS. SSH logs you in on port 22; nginx serves the web on 80 and 443. The keys don't exist yet.
ssh-keygen makes two files: a private key that never leaves your laptop, and a public key you can hand out freely.
ssh-keygen -t ed25519Predict the next step Which of the two new files goes onto the server? (The private key, id_ed25519 · The public key, id_ed25519.pub)Put the public key in authorized_keys on the server. Only its owner may write to that file, or sshd refuses to use it.
ssh-copy-id username@target_machineOn the first connection the server shows its host key fingerprint. Your laptop records it in known_hosts and warns loudly if it ever changes.
ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pubYour laptop proves it holds the private key by signing for this session; sshd checks it against authorized_keys. With passwords off, guessing gets an attacker nowhere.
PasswordAuthentication no PermitRootLogin noufw's default policy denies incoming traffic. Allow 22 for SSH, 80 and 443 for the web, then enable it. The app's port stays on 127.0.0.1.
sudo ufw allow 22 sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw enablePredict the next step CI runs ssh to the site's Cloudflare-proxied hostname. What happens? (It never reaches the server · Cloudflare forwards it to port 22)Cloudflare proxies web ports like 80 and 443, not SSH. CI's ssh to a proxied hostname never reaches the server, so point it at a DNS-only name.
How does SSH key authentication work?
ssh-keygen -t ed25519 creates two files: a private key, ~/.ssh/id_ed25519, and a public key, ~/.ssh/id_ed25519.pub. The private key never leaves your laptop, and its manual says the file should not be readable by anyone but you. The public key needs no secrecy at all. You add it to ~/.ssh/authorized_keys on the server, the file that lists the public keys allowed to log in as that user.
Read more (2)Show less
When you connect, the client signs data unique to that session with the private key, and the server checks two things: that the public key is in authorized_keys, and that the signature is correct. Owning the private key is the proof, so the key itself never crosses the network. Once password login is switched off, an attacker trying passwords gets nowhere, because the server no longer accepts any.
The server proves who it is, too. On the first connection ssh shows a fingerprint of the server's host key and, once you accept it, stores it in ~/.ssh/known_hosts. If that key ever changes, ssh prints a loud warning and refuses to continue, because a changed host key can mean someone is sitting between you and your server.
Why does SSH care about file permissions?
A Linux file mode has read (4), write (2) and execute (1) bits for three audiences: the owner, the group and everyone else. chmod 600 means read and write for the owner only. chmod 700 on a folder means only the owner can open it. The recommended modes are 700 for ~/.ssh and 600 for authorized_keys and for your private key.
Read more (1)Show less
OpenSSH enforces this rather than trusting you. With StrictModes on, which is the default, sshd refuses an authorized_keys file when it, the ~/.ssh folder or the home folder can be written by other users, and the ssh client ignores a private key that others can access. So chmod 777 to fix a failed login makes things worse. The fix is the opposite: take permissions away.
Which ports should a web server have open?
A port is a numbered door on the machine. A web server needs three of them open to the internet: 22 for SSH, 80 for HTTP and 443 for HTTPS. Everything else stays closed. On Ubuntu the tool for this is ufw, the default firewall configuration tool, which is installed disabled with a default incoming policy of deny. Allow 22 before you enable it: enabling ufw may drop existing connections, including the SSH session you are typing in.
Read more (1)Show less
Docker adds a trap. Traffic to a port published by a container is routed before ufw's rules are applied, so a container published on all interfaces is reachable even when ufw says that port is closed. Docker's own documentation calls the two incompatible. The fix is to publish app ports on 127.0.0.1 only, which is what this site's compose file does: nginx, on the same machine, is the only thing that reaches the app.
Why does SSH time out behind Cloudflare's proxy?
When a DNS record is proxied, Cloudflare answers lookups with its own anycast addresses and sits between your visitors and your server. It proxies a fixed list of web ports, such as 80 and 443. Port 22 is not on that list, and Cloudflare's own documentation uses an SSH server on port 22 as its example of traffic that needs a DNS-only record instead.
Read more (1)Show less
So SSH to a proxied hostname never reaches your server, and a CI deploy that uses one hangs until it times out. This site's domain is proxied, which is why its deploy reads the SSH host from a secret that can point at a DNS-only name or the server's address. The trade-off is real: a DNS-only record shows your server's address to anyone who looks it up.
The real config behind this lesson
Excerpts from the repository that builds and deploys this website, quoted as they are.
- name: Deploy over SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_SSH_HOST }}
username: ${{ secrets.VPS_SSH_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ vars.VPS_SSH_PORT || 22 }}
script: |
set -euo pipefail
cd "${{ vars.DEPLOY_PATH }}"The deploy job's SSH step. Host, user and private key come from encrypted secrets, and the port from a variable that falls back to 22, so no address or user name is written in the repository. set -euo pipefail stops the remote script at the first failing command, so a failed cd can never run the commands that follow in the wrong folder.
# Write runtime env from GitHub Secrets/Variables. Quoted heredoc so
# shell does not expand anything in the (already-rendered) values.
cat > .env <<'ENVEOF'
GROQ_API_KEY=${{ secrets.GROQ_API_KEY }}
RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}
CONTACT_FROM_EMAIL=${{ vars.CONTACT_FROM_EMAIL }}
CONTACT_TO_EMAIL=${{ vars.CONTACT_TO_EMAIL }}
IMAGE=${{ needs.build-and-push.outputs.image }}
APP_PORT=${{ vars.APP_PORT || 3002 }}
ENVEOF
chmod 600 .envWhat the SSH session does first. The quoted heredoc marker, <<'ENVEOF', stops the server's shell from expanding anything inside the values GitHub has already filled in. chmod 600 then makes the file readable and writable by its owner only, the same mode SSH asks of authorized_keys.
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjsThe same least-privilege idea, inside the container. The final stage creates a system user with uid 1001, gives it ownership of the app files, and switches to it with USER before the app starts, so the Node process never runs as root.
Mistakes that cost real time
Locking yourself out of the server
Two classic ways: a typo in sshd_config followed by a restart, and enabling ufw before allowing 22. Keep a copy of the original config, run sudo sshd -t to check the file before sudo systemctl restart ssh.service, and keep one session open while you test the change from a second one.
Trusting ufw to hide Docker ports
A container started with -p 3000:3000 is reachable from the internet even with ufw enabled, because Docker routes published-port traffic before ufw's rules apply. Publish with -p 127.0.0.1:3000:3000 and let a reverse proxy on the same machine forward to it.
chmod 777 to fix a login
SSH refuses keys and folders that other users can write to or read, so opening permissions up turns one failed login into a permanent one. Set 700 on ~/.ssh and 600 on authorized_keys and the private key.
SSH to a proxied hostname
Switch a hostname to proxied in Cloudflare and every deploy that SSHes to it hangs until it times out, while the website keeps working. Give CI a DNS-only name or the server's address.
What I'd change here: guard the deploy key like root
The CI key logs in as a user who can run Docker, and Docker's documentation says the docker group grants root-level privileges. Treat that key as a root credential and rotate it. The two SSH actions here are pinned to tags, not commit SHAs as GitHub's secure-use guide advises, and neither sets the fingerprint input that pins the server's host key.
| Proxied | DNS-only | |
|---|---|---|
| A DNS lookup returns | A Cloudflare anycast address | Your server's real address |
| Web traffic on 80 and 443 | Passes through Cloudflare, which can cache and protect it | Goes straight to your server |
| SSH on port 22 | Not forwarded to your server | Reaches sshd directly |
| Use it for | The website | SSH, CI deploys and other non-HTTP services |
Do it on your own machine
You need Docker and an ssh client (macOS, Linux or WSL). The lab runs a throwaway SSH server in a container on your own machine, published on localhost only, so nothing here touches a real server.
In an empty folder, create this Dockerfile. It installs OpenSSH, adds a normal user, installs the lab key and switches off password and root login.
FROM ubuntu:24.04 RUN apt-get update \ && apt-get install -y --no-install-recommends openssh-server \ && rm -rf /var/lib/apt/lists/* \ && mkdir -p /run/sshd \ && useradd --create-home --shell /bin/bash deployer COPY --chown=deployer:deployer --chmod=600 id_lab.pub /home/deployer/.ssh/authorized_keys RUN chmod 700 /home/deployer/.ssh \ && printf 'PasswordAuthentication no\nPermitRootLogin no\n' > /etc/ssh/sshd_config.d/99-lab.conf EXPOSE 22 CMD ["/usr/sbin/sshd", "-D", "-e"]Generate a lab key, build the image and run it on 127.0.0.1:2222. docker port confirms the port is published on localhost only.
ssh-keygen -t ed25519 -f ./id_lab -N '' -C 'devops-lab' # lab only; use a passphrase for real keys docker build -t ssh-lab . docker run -d --name ssh-lab -p 127.0.0.1:2222:22 ssh-lab docker port ssh-labLog in with the key and type yes at the fingerprint prompt. Then print the server's own host key fingerprint and check that it matches what you accepted. The listing shows drwx------ and -rw-------, modes 700 and 600.
ssh -i ./id_lab -p 2222 deployer@127.0.0.1 'whoami; ls -ld ~/.ssh ~/.ssh/authorized_keys' docker exec ssh-lab ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pubTry the doors that should be shut. Without the key, and as root, both attempts end in Permission denied (publickey). sshd -T prints the settings sshd is really using.
ssh -p 2222 -o PubkeyAuthentication=no deployer@127.0.0.1 # Permission denied (publickey) ssh -i ./id_lab -p 2222 root@127.0.0.1 # refused too docker exec ssh-lab sshd -T | grep -E '^(passwordauthentication|permitrootlogin) 'Rebuild the server from scratch so it gets a new host key, then connect again. ssh stops with WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED, the known_hosts check from the diagram.
docker rm -f ssh-lab docker build --no-cache -t ssh-lab . docker run -d --name ssh-lab -p 127.0.0.1:2222:22 ssh-lab ssh -i ./id_lab -p 2222 deployer@127.0.0.1Clean up the container and the lab entry in your known_hosts.
docker rm -f ssh-lab ssh-keygen -R '[127.0.0.1]:2222'
Three questions before you move on
Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.
Answer in your head, then check
1.Which half of the key pair goes on the server?Show the answer
Answer:The public key, in ~/.ssh/authorized_keys. The server only has to check signatures. The ssh-keygen manual says the private key file should not be readable by anyone but you, and that the public key needs no secrecy.
2.OpenSSH's default for PermitRootLogin is prohibit-password. Why set it to no anyway?Show the answer
Answer:prohibit-password only switches off password and keyboard-interactive login for root, so root can still log in with a key. no forces everyone in as a normal user, so one stolen key is not instantly root.
3.After the server's hostname is switched to proxied in Cloudflare, the CI deploy times out on port 22. Why?Show the answer
Answer:Cloudflare proxies a fixed list of HTTP and HTTPS ports, and 22 is not on it. Its documentation tells you to use a DNS-only record for an SSH server on port 22, so give CI a DNS-only name or the server's address.
4.ufw is enabled and allows only 22, 80 and 443, yet a container started with -p 3000:3000 answers from the internet. Why?Show the answer
Answer:Docker routes traffic for published ports before ufw's rules are applied, so ufw never sees it. Publish on 127.0.0.1 instead and put a reverse proxy in front.
Related deep dives on this site
Sources
Primary sources checked on the research date. Follow them for the current version.
- ssh-keygen(1) manual page
- ssh(1) manual page: known_hosts and authorized_keys
- sshd_config(5): PasswordAuthentication, PermitRootLogin, StrictModes
- sshd(8) manual page: file permissions and -T
- RFC 4252: SSH authentication protocol (public key method)
- Ubuntu Server docs: OpenSSH server
- Ubuntu Server docs: Firewalls (ufw)
- ufw(8) manual page
- Docker docs: Packet filtering and firewalls
- Docker docs: Linux post-installation steps (the docker group)
- Cloudflare docs: Network ports
- Cloudflare docs: Proxy status (proxied vs DNS-only)
- appleboy/ssh-action README (fingerprint input)
- GitHub docs: Secure use reference (pinning actions)