Skip to main content
DevOps from zero · Lesson 2 of 8 · 12 min

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.
Watch it work

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.

Step 1 of 7. 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.

Keys: ← → step · Home / End · K play or pause

  1. 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.

  2. 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)
  3. 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_machine
  4. On 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.pub
  5. Your 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 no
  6. ufw'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)
  7. 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)

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)

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)

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)

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.

How this site does it

The real config behind this lesson

Excerpts from the repository that builds and deploys this website, quoted as they are.

.github/workflows/deploy.yml:213-222
      - 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.

.github/workflows/deploy.yml:224-234
            # 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 .env

What 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.

Dockerfile:58-64
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 nextjs

The 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.

What breaks

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 or DNS-only: which hostname for what
A DNS lookup returnsA Cloudflare anycast addressYour server's real address
Web traffic on 80 and 443Passes through Cloudflare, which can cache and protect itGoes straight to your server
SSH on port 22Not forwarded to your serverReaches sshd directly
Use it forThe websiteSSH, CI deploys and other non-HTTP services
Try it yourself

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.

  1. 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"]
  2. 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-lab
  3. Log 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.pub
  4. Try 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) '
  5. 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.1
  6. Clean up the container and the lab entry in your known_hosts.

    docker rm -f ssh-lab
    ssh-keygen -R '[127.0.0.1]:2222'
Quick quiz

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.

  1. 1.Your key login fails with Permission denied (publickey). A forum post says to run chmod 777 on ~/.ssh on the server. What happens if you do?
  2. 2.On a fresh VPS, logged in over SSH, you run sudo ufw enable first and plan to allow port 22 afterwards. What is the risk?
  3. 3.You rebuild your server from scratch at the same address. Now ssh stops with WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED. What does it mean?
Think it through

Answer in your head, then check

  1. 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. 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. 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. 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.

Go deeper

Related deep dives on this site