10 — Your fake VPS
Read this first: this lesson builds the "server" you will use for the rest of the course — a real Linux box you SSH into, running on your own machine, that you can destroy and rebuild in twenty seconds. It covers SSH keys, running a command remotely, copying files, and the permission rule that makes SSH fail with a message that explains nothing.
Time: about 45 minutes. Assumes lesson 05.
What we are building and what we are pretending
The lab box is a container running Ubuntu 24.04, sshd, and its own Docker daemon. You log in as a
deploy user, it has sudo, code lives in /srv/, and every command you learn transfers to a real
server.
What it cannot teach you, stated up front because a course that pretends otherwise produces exactly the false confidence this one exists to fix:
| Not available | Why |
|---|---|
| Real DNS | Nothing on your laptop has a public name |
| Real TLS certificates | Let's Encrypt must reach you from the internet to issue one |
| A real public IP | And therefore no real hostile traffic |
| systemd | Containers do not boot an init system; ours runs sshd directly |
Those four get read-only tours in Track B rather than labs.
Start it
cd learn-devops/lab-vps
./up.sh
On the first run this generates a keypair, builds the image, and starts the box. Then:
./ssh.sh
deploy@lab-vps:~$
You are on a different machine now — a different filesystem, a different process list, a different hostname. Look around:
whoami; hostname; id
deploy
lab-vps
uid=1001(deploy) gid=1001(deploy) groups=1001(deploy),27(sudo),996(docker)
grep PRETTY /etc/os-release
PRETTY_NAME="Ubuntu 24.04.4 LTS"
Type exit to come back.
Keys, not passwords
The box has PasswordAuthentication no in its sshd config — exactly like the real VPS after
deploy/bootstrap.sh hardens it. The only way in is a key.
A keypair is two files:
| File | What it is | Who gets it |
|---|---|---|
id_ed25519 | The private key | Nobody. Ever. It stays on your machine. |
id_ed25519.pub | The public key | Copied to the server, into ~/.ssh/authorized_keys |
The mental model: the public key is a lock you mail out; the private key is the only thing that opens it. A server holding your public key can verify you without ever being able to impersonate you.
up.sh generated the pair with:
ssh-keygen -t ed25519 -f keys/id_ed25519 -N '' -C 'learn-devops-lab'
-N '' means no passphrase, which is fine for a throwaway key that never leaves your machine and
wrong for anything that can reach something real.
Running one command instead of opening a shell
This is the part that matters for CI, and it is easy to miss:
./ssh.sh 'docker ps'
SSH ran that command on the box and gave you the output. No interactive session, no terminal. That is precisely what a deploy step in GitHub Actions does — it opens an SSH connection, runs a script, and reads the exit code. Everything you learned about exit codes in lesson 01 applies across the network.
Copy a file up with scp:
scp -i keys/id_ed25519 -P 2222 ./somefile deploy@localhost:/srv/greeter/
Note -P 2222 for scp, capital P — ssh uses lowercase -p. This inconsistency has wasted more
time than it has any right to.
Break it on purpose: the private key everyone can read
Predict: what happens if your private key is readable by other users on your machine?
chmod 644 keys/id_ed25519
./ssh.sh
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: UNPROTECTED PRIVATE KEY FILE! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
Permissions 0644 for 'keys/id_ed25519' are too open.
It is required that your private key files are NOT accessible by others.
SSH refuses to use it. That is the client protecting you from yourself. Fix it:
chmod 600 keys/id_ed25519
600 is rw-------: owner reads and writes, nobody else sees anything. Those are the permission
bits from lesson 01, and this is the first time they have refused to let you
proceed.
Break it on purpose: the error that explains nothing
This one is worth your full attention, because the client-side message is actively misleading.
Predict: on the server, ~/.ssh/authorized_keys is owned by the wrong user. What does the
client see?
deploy@localhost: Permission denied (publickey).
That is all. No mention of ownership, no mention of the file. The real reason is in the server's log:
docker logs lab-vps | tail -3
Authentication refused: bad ownership or modes for file /home/deploy/.ssh/authorized_keys
Connection closed by authenticating user deploy 172.16.11.1 port 52236 [preauth]
sshd refuses to trust an authorized_keys file that anyone but the owner could have edited. If
it did trust one, any user who could write that file could grant themselves access to the account.
The rules are:
| Path | Required |
|---|---|
~/.ssh | Owned by the user, mode 700 |
~/.ssh/authorized_keys | Owned by the user, mode 600 |
The lesson to carry: when SSH says Permission denied (publickey), the useful information is on
the server, not the client. Get at it with docker logs lab-vps here, or journalctl -u ssh on a
real box. The client will never tell you.
This is a real trap and not a contrived one. This very lab hit it during construction: the public key
was bind-mounted straight onto authorized_keys, which kept its owner from the host — a different
uid than the container's deploy user. The box was correct, the key was correct, and login failed
with four words. The entrypoint now installs the key with the right owner and mode instead.
Diagnose from the client side with -vvv, which prints every step of the negotiation:
ssh -vvv -i keys/id_ed25519 -p 2222 deploy@localhost
It will show the key being offered and rejected. It still will not tell you why — but knowing that the key was offered rules out half the possible causes.
Your escape hatch
./reset.sh
Destroys the box and builds a fresh one in about twenty seconds, keeping the image cache. Every dangerous drill from here on tells you to break something; this is how you get back. Use it freely — the entire reason the lab is a container is that the reset button is free.
If something is deeply confused (usually iptables state after lesson 13):
./reset.sh --cold
Where this shows up in MotorPH
- deploy/bootstrap.sh creates the
deployuser, installs a public key, and only then turns off password authentication — with a comment noting that hardening sshd before a key is installed is how you lose access to the box. - Every deploy job uses
appleboy/ssh-action, which is this same "run a command over SSH" mechanism with the key supplied from a GitHub Secret. You will use it in lesson 18. - The real workflows connect to
VPS_HOSTas an IP address, not the domain — the domain resolves to the CDN, and a CDN does not proxy SSH.
Recap
- A keypair is a public lock you distribute and a private key you never do; the server holds only the public half.
ssh host 'command'runs one command and returns its exit code — that is exactly what a CI deploy step does.- Permissions are enforced on both ends:
600on your private key,700/600on the server's.sshdirectory andauthorized_keys. Permission denied (publickey)tells you nothing. The server's log tells you everything.
Next: 11 — Users and permissions.