How to buildOctober 11, 202612 min read
Docker and deploymentStep 2 of 8

Linux for developers: files, permissions, processes, SSH

In short

To work confidently on a Linux server, a developer needs five skills: logging in with an SSH key, working as a regular user with sudo, understanding file owners and permissions, finding processes and busy ports, and running the app as a systemd service with logs in journalctl. Below is the whole initial setup of an Ubuntu 24.04 LTS VPS with real command output and a final checklist.

TutorialLevel: BeginnerLinuxPractice: ~45–90 min

After this you will be able to

  • create an ed25519 SSH key, verify the server fingerprint and log in through an alias in ~/.ssh/config
  • set up a user with sudo, fix ~/.ssh permissions and find the cause of an access denial with journalctl and namei -l
  • find a process and a busy port with pgrep, ps and ss -tlnp and check the app's response with curl
  • describe the app as a systemd service with restarts, read its journal and disable password login after checking with sshd -t

Verified with

Ubuntu 24.04.5 LTSOpenSSH 9.6p1systemd 255bash 5.2ufw 0.36.2Python 3.12 (http.server)Node.js 24.18

Checked against docs: October 11, 2026

Contents
  1. 01The server from the inside: where things live
  2. 02SSH: a key instead of a password
  3. 03Users, groups and sudo
  4. 04Owners and permissions on a server: chown, umask, namei
  5. 05Processes, ports and curl
  6. 06systemd: the app as a service, and logs
  7. 07Packages, the firewall and key-only login
  8. 08Initial setup checklist and exercises

The server from the inside: where things live

A VPS is a remote Linux machine without a graphical interface: everything is done with commands over SSH. This lesson continues “Terminal and command line”, which already covers navigation, redirection and chmod basics. Here we cover what only shows up on a server: other users, services, networking and security. The examples were checked on Ubuntu 24.04.5 LTS. Ubuntu LTS releases get five years of standard security maintenance; for 24.04 that runs until May 2029.

We tested on two Docker containers: a “server” with systemd and OpenSSH and a “laptop” with only the SSH client. In the examples the server address is replaced with the documentation address 203.0.113.10 — use your VPS IP. The # prompt means a command run as root, $ — as a regular user.

/etc

What lives there
Configuration of this machine
Example
/etc/ssh/sshd_config, /etc/systemd/system

/home, /root

What lives there
Home directories of users and root
Example
/home/deploy/.ssh/authorized_keys

/srv

What lives there
Data the server serves to others
Example
/srv/app — the project in this lesson

/opt

What lives there
Third-party software, each in its own folder
Example
A vendor's monitoring agent

/var/log, /var/lib

What lives there
Log files and changing program state
Example
auth.log, dpkg.log; database data

/tmp

What lives there
Temporary files, may be deleted without notice
Example
Nothing valuable

/usr/bin, /usr/local/bin

What lives there
Programs; local holds manually installed ones
Example
/usr/local/bin/node
terminal
# ls -C /
bin		   etc		      lib64  proc  sbin.usr-is-merged  usr
bin.usr-is-merged  home		      media  root  srv		       var
boot		   lib		      mnt    run   sys
dev		   lib.usr-is-merged  opt    sbin  tmp
# df -h /
Filesystem      Size  Used Avail Use% Mounted on
overlay        1007G  156G  800G  17% /
# du -sh /var/log /usr
26M	/var/log
280M	/usr
# ls -C /var/log
README		  auth.log	 dmesg	   journal   private
alternatives.log  bootstrap.log  dpkg.log  kern.log  syslog
apt		  btmp		 faillog   lastlog   wtmp
Ubuntu 24.04 in a container: the overlay filesystem and its size come from Docker; on a VPS you will see a disk like /dev/vda1. The *.usr-is-merged directories are service markers of the /bin-into-/usr/bin merge in Ubuntu 24.04.

SSH: a key instead of a password

SSH is an encrypted channel to the server. A key has two parts: the private one stays on your laptop, the public one goes to the server into ~/.ssh/authorized_keys. A password can be brute-forced; a key, in practice, cannot. The Ubuntu documentation recommends the ed25519 type — it is shorter and faster than RSA; if you need RSA somewhere, use a 4096-bit key.

terminal
$ ssh-keygen -t ed25519 -C "dev@laptop"
Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/dev/.ssh/id_ed25519): 
Created directory '/home/dev/.ssh'.
Enter passphrase (empty for no passphrase): 
Enter same passphrase again: 
Your identification has been saved in /home/dev/.ssh/id_ed25519
Your public key has been saved in /home/dev/.ssh/id_ed25519.pub
The key fingerprint is:
SHA256:rQv/ITuF0G35TpciyF1zw3hCADi0ObFTTv7dfQ/g2J0 dev@laptop
$ ls -l ~/.ssh
total 16
-rw------- 1 dev dev 444 Oct 12 01:35 id_ed25519
-rw-r--r-- 1 dev dev  92 Oct 12 01:35 id_ed25519.pub
$ cat ~/.ssh/id_ed25519.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICJoFk+NOCBCDZTysLYYWbhJNTTZN/nijzyyyRBFj42/ dev@laptop
On the laptop: OpenSSH 9.6p1, real output; the randomart picture after the fingerprint is omitted. The passphrase is not echoed while you type.
  • The passphrase encrypts the private key on disk: if the laptop is stolen, the key is useless without it
  • The .pub file can be shown to anyone — that is what goes to the server; the private id_ed25519 is never shared, not even with colleagues
  • ssh-keygen sets the private key permissions to 600 itself — owner only
  • One key per device: lose a laptop and you delete one line from authorized_keys while other access keeps working

How the public key gets to the server. Most VPS providers have an “SSH key” field when you create a server — paste the contents of id_ed25519.pub there and it ends up in /root/.ssh/authorized_keys. If the provider gave you only a login and password, use ssh-copy-id: it logs in with the password one last time and appends the key. By default OpenSSH does not let root log in with a password (PermitRootLogin prohibit-password), so a root password only works if the provider changed that setting.

terminal
$ ssh-copy-id -i ~/.ssh/id_ed25519.pub ops@203.0.113.10
/usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "/home/dev/.ssh/id_ed25519.pub"
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
ops@203.0.113.10's password: 

Number of key(s) added: 1

Now try logging into the machine, with:   "ssh 'ops@203.0.113.10'"
and check to make sure that only the key(s) you wanted were added.
Checked with an ops account that had a password (password login was temporarily allowed for that account only and removed after the test).

On the first connection the client shows the server's key fingerprint and asks whether to trust it. After yes the fingerprint is stored in ~/.ssh/known_hosts, and if it ever changes, SSH refuses to connect with a loud warning — protection against a spoofed server.

terminal
$ ssh root@203.0.113.10 'hostname; whoami'
The authenticity of host '203.0.113.10 (203.0.113.10)' can't be established.
ED25519 key fingerprint is SHA256:IaTy+ntmy5vPYW0mWxRtuKucgJ/6xml8wEPCiK2pPoA.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '203.0.113.10' (ED25519) to the list of known hosts.
Enter passphrase for key '/home/dev/.ssh/id_ed25519': 
vps
root
First login from the laptop, real output. The test server is named vps.
terminal
# ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
256 SHA256:IaTy+ntmy5vPYW0mWxRtuKucgJ/6xml8wEPCiK2pPoA root@vps (ED25519)
On the server, via the provider's web console: the same SHA256 fingerprint, so we connected to our own server.

Users, groups and sudo

Working as root is dangerous: any typo runs with full privileges. On Ubuntu the root password is locked by default, and admin rights are granted through sudo to members of the sudo group. So the first step on a new server is to create a regular user, for example deploy, and give it sudo.

terminal
# adduser deploy
info: Adding user `deploy' ...
info: Selecting UID/GID from range 1000 to 59999 ...
info: Adding new group `deploy' (1000) ...
info: Adding new user `deploy' (1000) with group `deploy (1000)' ...
info: Creating home directory `/home/deploy' ...
info: Copying files from `/etc/skel' ...
New password: 
Retype new password: 
passwd: password updated successfully
Changing the user information for deploy
Enter the new value, or press ENTER for the default
	Full Name []: 
	Room Number []: 
	Work Phone []: 
	Home Phone []: 
	Other []: 
Is the information correct? [Y/n] Y
info: Adding new user `deploy' to supplemental / extra groups `users' ...
info: Adding user `deploy' to group `users' ...
# usermod -aG sudo deploy
# id deploy
uid=1000(deploy) gid=1000(deploy) groups=1000(deploy),27(sudo),100(users)
# ls -ld /home/deploy
drwxr-x--- 2 deploy deploy 4096 Oct 12 01:35 /home/deploy
Ubuntu 24.04, adduser 3.137: you can just press Enter at the name and phone questions. The deploy password will be needed for sudo; SSH login will use the key.
  • adduser is Ubuntu's friendly wrapper: it creates a group with the same name and a home directory and asks for a password. The low-level useradd without flags does not even create a home directory
  • uid=1000(deploy) gid=1000(deploy) are numeric IDs: the kernel checks permissions by numbers, names are for humans
  • The -a flag in usermod -aG is mandatory: without it usermod -G users deploy replaces the group list and the user drops out of sudo (verified). The Ubuntu documentation variant is adduser deploy sudo
  • On Ubuntu 21.10 and newer, home directories are created with 750 permissions: other users cannot look inside

Now the key: copy root's authorized_keys into deploy's home. A typical mistake is copying as root and forgetting about the owner.

terminal
# install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
# cp /root/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys
# ls -l /home/deploy/.ssh
total 4
-rw------- 1 root root 92 Oct 12 01:35 authorized_keys
install -d creates the folder with permissions and owner at once; but cp run as root left root as the file owner.
terminal
$ ssh -o BatchMode=yes deploy@203.0.113.10 whoami
deploy@203.0.113.10: Permission denied (publickey,password).

# journalctl -u ssh -n 2 --no-pager
Oct 12 01:36:04 vps sshd[278]: Could not open user 'deploy' authorized keys '/home/deploy/.ssh/authorized_keys': Permission denied
Oct 12 01:36:04 vps sshd[278]: Connection closed by authenticating user deploy 198.51.100.23 port 52416 [preauth]
The laptop first, then the server log. BatchMode=yes stops the client from asking for a password, so we see that the key was rejected.

The client error explains nothing, while the server log names the cause directly: sshd reads keys as user deploy, but the file belongs to root and is locked with 600. Fix the owner:

terminal
# chown deploy:deploy /home/deploy/.ssh/authorized_keys
# chmod 600 /home/deploy/.ssh/authorized_keys
# ls -la /home/deploy/.ssh
total 12
drwx------ 2 deploy deploy 4096 Oct 12 01:35 .
drwxr-x--- 3 deploy deploy 4096 Oct 12 01:35 ..
-rw------- 1 deploy deploy   92 Oct 12 01:35 authorized_keys
After chown key login works.

To avoid typing the IP and user name every time, create ~/.ssh/config on the laptop with an alias, and let ssh-agent hold the key passphrase — it remembers it until the end of the session.

~/.ssh/config
Host myvps
    HostName 203.0.113.10
    User deploy
    IdentityFile ~/.ssh/id_ed25519
    IdentitiesOnly yes
The first value found wins, so specific Host blocks go above general rules. The file must not be writable by others — set 600.
terminal
$ eval "$(ssh-agent -s)"
Agent pid 363
$ ssh-add
Enter passphrase for /home/dev/.ssh/id_ed25519: 
Identity added: /home/dev/.ssh/id_ed25519 (dev@laptop)
$ chmod 600 ~/.ssh/config
$ ssh myvps 'whoami; id'
deploy
uid=1000(deploy) gid=1000(deploy) groups=1000(deploy),27(sudo),100(users)
$ ssh -t myvps 'sudo whoami; sudo -l'
[sudo] password for deploy: 
root
Matching Defaults entries for deploy on vps:
    env_reset, mail_badpass,
    secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin,
    use_pty

User deploy may run the following commands on vps:
    (ALL : ALL) ALL
Connection to 203.0.113.10 closed.
Laptop, real output. sudo asks for deploy's own password, not root's, and remembers it for a while.

Owners and permissions on a server: chown, umask, namei

How to read rwxr-x--- and what chmod 644 and chmod 600 mean is covered in the terminal lesson. On a server the question “who” is added: every file has an owner and a group, and every process runs as some user. Access is checked against the triple that matches the process: owner, otherwise group, otherwise everyone else. Let's prepare the project folder: create it with sudo and hand it to deploy.

terminal
$ sudo mkdir -p /srv/app/public
$ ls -ld /srv/app
drwxr-xr-x 3 root root 4096 Oct 12 01:37 /srv/app
$ sudo chown -R deploy:deploy /srv/app
$ ls -ld /srv/app /srv/app/public
drwxr-xr-x 3 deploy deploy 4096 Oct 12 01:37 /srv/app
drwxr-xr-x 2 deploy deploy 4096 Oct 12 01:37 /srv/app/public
$ umask
0002
$ echo '<h1>Hello from /srv/app</h1>' > /srv/app/public/index.html
$ printf 'PORT=8080\nAPP_SECRET=change-me\n' > /srv/app/.env
$ ls -la /srv/app /srv/app/public
/srv/app:
total 16
drwxr-xr-x 3 deploy deploy 4096 Oct 12 01:37 .
drwxr-xr-x 1 root   root   4096 Oct 12 01:37 ..
-rw-rw-r-- 1 deploy deploy   31 Oct 12 01:37 .env
drwxr-xr-x 2 deploy deploy 4096 Oct 12 01:37 public

/srv/app/public:
total 12
drwxr-xr-x 2 deploy deploy 4096 Oct 12 01:37 .
drwxr-xr-x 3 deploy deploy 4096 Oct 12 01:37 ..
-rw-rw-r-- 1 deploy deploy   29 Oct 12 01:37 index.html
$ chmod 600 /srv/app/.env
$ chmod 750 /srv/app
$ stat -c '%A %a %U:%G %n' /srv/app /srv/app/.env /srv/app/public/index.html
drwxr-x--- 750 deploy:deploy /srv/app
-rw------- 600 deploy:deploy /srv/app/.env
-rw-rw-r-- 664 deploy:deploy /srv/app/public/index.html
$ sudo adduser --disabled-password --comment "" intern > /dev/null
$ sudo -u intern cat /srv/app/public/index.html
cat: /srv/app/public/index.html: Permission denied
$ sudo -u intern ls /srv/app
ls: cannot open directory '/srv/app': Permission denied
$ namei -l /srv/app/public/index.html
f: /srv/app/public/index.html
drwxr-xr-x root   root   /
drwxr-xr-x root   root   srv
drwxr-x--- deploy deploy app
drwxr-xr-x deploy deploy public
-rw-rw-r-- deploy deploy index.html
deploy session over SSH, real output. The intern user exists only to test access.
  • sudo mkdir created the folder as root — deploy could not write into it. chown -R deploy:deploy handed over owner and group recursively
  • umask 0002 is the permission mask for new files: 002 is removed from 666, giving 664 (rw-rw-r--). That is why .env was world-readable after creation and had to be locked explicitly with chmod 600
  • The x permission on a folder means “may enter”. Outsiders cannot enter /srv/app with 750, so the index.html inside with 664 is out of their reach too
  • namei -l shows the permissions at every step of the path — you immediately see which directory blocks access

~/.ssh

Permissions and owner
700, owned by the user
Why
sshd rejects keys if others can modify the folder

~/.ssh/authorized_keys

Permissions and owner
600, owned by the user
Why
Read as the user; only the user writes to it

/srv/app

Permissions and owner
750, deploy:deploy
Why
Full access for the app, read for the group, nothing for others

/srv/app/.env

Permissions and owner
600, owned by the service user
Why
Only the app sees passwords and keys

/etc/systemd/system/*.service

Permissions and owner
644, root:root
Why
Only the administrator edits the service definition

Processes, ports and curl

A process is a running program with a PID and an owning user. Let's start a simple Python web server from the project folder in the background and look at it with the main tools: pgrep finds processes by name, ps gives a snapshot of the list, top a live load table (quit with q).

terminal
$ nohup python3 -m http.server 8080 --bind 127.0.0.1 --directory /srv/app/public > /tmp/http.log 2>&1 &
$ sleep 1; pgrep -a python3
2241 python3 -m http.server 8080 --bind 127.0.0.1 --directory /srv/app/public
$ ps -eo pid,user,%cpu,%mem,cmd --sort=-%mem | head -5
    PID USER     %CPU %MEM CMD
   2241 deploy    0.9  0.0 python3 -m http.server 8080 --bind 127.0.0.1 --directory /srv/app/public
     24 root      0.0  0.0 /usr/lib/systemd/systemd-journald
      1 root      0.0  0.0 /sbin/init
   2225 root      0.0  0.0 sshd: deploy [priv]
$ top -b -n 1 | head -8
top - 01:47:17 up 3 days, 11:53,  0 user,  load average: 2.15, 3.11, 3.34
Tasks:  20 total,   1 running,  19 sleeping,   0 stopped,   0 zombie
%Cpu(s):  0.0 us,  0.3 sy,  0.0 ni, 97.9 id,  0.0 wa,  0.0 hi,  1.7 si,  0.0 st 
MiB Mem :  31936.7 total,   4438.6 free,   4555.4 used,  23685.6 buff/cache     
MiB Swap:   8192.0 total,   8190.3 free,      1.7 used.  27381.2 avail Mem 

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
      1 root      20   0   21336  12752   9684 S   0.0   0.0   0:00.63 systemd
$ sudo ss -tlnp
State  Recv-Q Send-Q Local Address:Port  Peer Address:PortProcess                                                
LISTEN 0      4096         0.0.0.0:22         0.0.0.0:*    users:(("sshd",pid=2066,fd=3),("systemd",pid=1,fd=93))
LISTEN 0      4096      127.0.0.11:41039      0.0.0.0:*                                                          
LISTEN 0      5          127.0.0.1:8080       0.0.0.0:*    users:(("python3",pid=2241,fd=3))                     
LISTEN 0      4096            [::]:22            [::]:*    users:(("sshd",pid=2066,fd=4),("systemd",pid=1,fd=94))
$ curl -sS http://127.0.0.1:8080/
<h1>Hello from /srv/app</h1>
$ curl -sI http://127.0.0.1:8080/
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.12.3
Date: Mon, 12 Oct 2026 01:47:17 GMT
Content-type: text/html
Content-Length: 29
Last-Modified: Mon, 12 Oct 2026 01:37:06 GMT

$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/missing
404
$ curl -sS http://127.0.0.1:8081/
curl: (7) Failed to connect to 127.0.0.1 port 8081 after 0 ms: Couldn't connect to server
$ kill $(pgrep -f 'http[.]server 8080')
$ sleep 1; pgrep -a python3 || echo "no python3 processes"
no python3 processes
$ cat /tmp/http.log
127.0.0.1 - - [12/Oct/2026 01:47:17] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [12/Oct/2026 01:47:17] "HEAD / HTTP/1.1" 200 -
127.0.0.1 - - [12/Oct/2026 01:47:17] code 404, message File not found
127.0.0.1 - - [12/Oct/2026 01:47:17] "GET /missing HTTP/1.1" 404 -
deploy session, real output. The 127.0.0.11:41039 line is Docker's internal DNS and will not appear on a VPS; load and memory in top belong to the test host.

ss -tlnp is the main answer to “who is listening on this port”: t — TCP, l — listening sockets only, n — numbers instead of names, p — the process. Without sudo, other users' processes are not shown. Note the address: 127.0.0.1:8080 is reachable only from the server itself, 0.0.0.0:22 — from all interfaces. On Ubuntu 24.04 port 22 is held by systemd itself (the ssh.socket unit), which starts sshd on connection — you can see it in the process column.

curl -sS URL

What it shows
The response body; -s hides the progress meter, -S keeps error messages

curl -sI URL

What it shows
Response headers only: status, type, length

curl -s -o /dev/null -w '%{http_code}\n' URL

What it shows
Only the status code — handy in check scripts

curl: (7) Failed to connect

What it shows
Nobody listens on the port: the process is not running or listens on another address

SIGTERM (15)

How to send
kill PID — the default
What happens
A request to exit: the program can close connections and save data

SIGINT (2)

How to send
Ctrl+C in a terminal
What happens
Interrupt from the terminal, usually behaves like SIGTERM

SIGHUP (1)

How to send
kill -HUP PID
What happens
Many daemons reload their configuration on it

SIGKILL (9)

How to send
kill -9 PID
What happens
Immediate kill by the kernel, cannot be caught — a last resort

A process started with nohup … & survives logging out of SSH, but not a reboot, and if it crashes nobody brings it back. An app on a server needs a service manager — systemd.

systemd: the app as a service, and logs

systemd is the first process of the system (PID 1): it starts services at boot, restarts crashed ones and collects their output into the journal. A service is described by a unit file. Your own units go to /etc/systemd/system. Let's describe our web server — it takes the port from .env.

/etc/systemd/system/hello-web.service
[Unit]
Description=Hello web: static files from /srv/app/public
After=network.target

[Service]
Type=exec
User=deploy
Group=deploy
WorkingDirectory=/srv/app
EnvironmentFile=/srv/app/.env
ExecStart=/usr/bin/python3 -m http.server ${PORT} --bind 127.0.0.1 --directory /srv/app/public
Restart=on-failure
RestartSec=2

[Install]
WantedBy=multi-user.target
In a real project, instead of Python you will have ExecStart=/usr/local/bin/node server.mjs — such a unit for Node.js is built in the exercise below. Create the file with sudo nano.

User=, Group=

Why
The process runs as deploy, not root

EnvironmentFile=

Why
Variables from .env; systemd reads the file, so the 600 permissions owned by deploy do not get in its way

ExecStart=

Why
The start command with an absolute path; ${PORT} is taken from the environment

Restart=on-failure, RestartSec=2

Why
Restart after 2 seconds if the process crashed

WantedBy=multi-user.target

Why
What systemctl enable hooks into: start on a normal boot
terminal
$ sudo cp /tmp/hello-web.service /etc/systemd/system/hello-web.service
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now hello-web
Created symlink /etc/systemd/system/multi-user.target.wants/hello-web.service → /etc/systemd/system/hello-web.service.
$ systemctl status hello-web --no-pager
● hello-web.service - Hello web: static files from /srv/app/public
     Loaded: loaded (/etc/systemd/system/hello-web.service; enabled; preset: enabled)
     Active: active (running) since Mon 2026-10-12 01:47:42 UTC; 5ms ago
   Main PID: 2384 (python3)
      Tasks: 1 (limit: 38311)
     Memory: 2.4M (peak: 2.4M)
        CPU: 3ms
     CGroup: /system.slice/hello-web.service
             └─2384 /usr/bin/python3 -m http.server 8080 --bind 127.0.0.1 --dir…
$ curl -sS http://127.0.0.1:8080/
curl: (7) Failed to connect to 127.0.0.1 port 8080 after 0 ms: Couldn't connect to server
$ sleep 1; curl -sS http://127.0.0.1:8080/
<h1>Hello from /srv/app</h1>
$ journalctl -u hello-web -n 5 --no-pager
Hint: You are currently not seeing messages from other users and the system.
      Users in groups 'adm', 'systemd-journal' can see all messages.
      Pass -q to turn off this notice.
Oct 12 01:47:43 vps python3[2384]: 127.0.0.1 - - [12/Oct/2026 01:47:43] "GET / HTTP/1.1" 200 -
$ sudo journalctl -u hello-web -n 5 --no-pager
Oct 12 01:47:42 vps systemd[1]: Starting hello-web.service - Hello web: static files from /srv/app/public...
Oct 12 01:47:42 vps systemd[1]: Started hello-web.service - Hello web: static files from /srv/app/public.
Oct 12 01:47:43 vps python3[2384]: 127.0.0.1 - - [12/Oct/2026 01:47:43] "GET / HTTP/1.1" 200 -
$ sudo kill -9 $(systemctl show -p MainPID --value hello-web)
$ sleep 3; systemctl status hello-web --no-pager | head -5
● hello-web.service - Hello web: static files from /srv/app/public
     Loaded: loaded (/etc/systemd/system/hello-web.service; enabled; preset: enabled)
     Active: active (running) since Mon 2026-10-12 01:47:46 UTC; 815ms ago
   Main PID: 2401 (python3)
      Tasks: 1 (limit: 38311)
$ sudo journalctl -u hello-web -n 6 --no-pager
Oct 12 01:47:43 vps python3[2384]: 127.0.0.1 - - [12/Oct/2026 01:47:43] "GET / HTTP/1.1" 200 -
Oct 12 01:47:43 vps systemd[1]: hello-web.service: Main process exited, code=killed, status=9/KILL
Oct 12 01:47:43 vps systemd[1]: hello-web.service: Failed with result 'signal'.
Oct 12 01:47:46 vps systemd[1]: hello-web.service: Scheduled restart job, restart counter is at 1.
Oct 12 01:47:46 vps systemd[1]: Starting hello-web.service - Hello web: static files from /srv/app/public...
Oct 12 01:47:46 vps systemd[1]: Started hello-web.service - Hello web: static files from /srv/app/public.
$ sudo kill $(systemctl show -p MainPID --value hello-web)
$ sleep 3; systemctl is-active hello-web; systemctl is-enabled hello-web
inactive
enabled
$ sudo systemctl start hello-web
$ systemctl is-active hello-web
active
$ sudo journalctl -u hello-web --since "10 min ago" -p warning --no-pager
Oct 12 01:47:43 vps systemd[1]: hello-web.service: Main process exited, code=killed, status=9/KILL
Oct 12 01:47:43 vps systemd[1]: hello-web.service: Failed with result 'signal'.
$ systemctl list-units --type=service --state=failed --no-pager
  UNIT LOAD ACTIVE SUB DESCRIPTION

0 loaded units listed.
deploy session, systemd 255, real output (colours removed).

Let's go through what happened. The first curl right after start got no answer: with Type=exec systemd considers the service started as soon as the program is executed, not when it opens the port. Post-deploy checks therefore retry the request a few times or wait for a health endpoint. The service survived kill -9: Restart=on-failure restarts a process killed by a signal. A plain kill (SIGTERM), however, counts as a clean exit — according to the systemd.service documentation, SIGHUP, SIGINT, SIGTERM and SIGPIPE are not failures, so the service stayed stopped. If you need it back no matter what, there is Restart=always.

-u hello-web

What it does
Only entries of this unit

-f

What it does
Follow new entries as they appear, like tail -f

-n 50

What it does
The last 50 lines (10 without a number)

--since "10 min ago", --since today

What it does
Entries for a period

-p warning

What it does
Only warnings and above

-b

What it does
Only since the last boot

Packages, the firewall and key-only login

Software on Ubuntu comes from repositories via apt. apt update only refreshes the package lists, apt upgrade installs new versions — so the first, then the second. apt is meant for humans; for scripts the Ubuntu documentation recommends apt-get. Package sources on 24.04 are described in /etc/apt/sources.list.d/ubuntu.sources, and by default Ubuntu Server applies security updates automatically.

terminal
$ sudo apt update
Hit:1 http://security.ubuntu.com/ubuntu noble-security InRelease
Hit:2 http://archive.ubuntu.com/ubuntu noble InRelease
Hit:3 http://archive.ubuntu.com/ubuntu noble-updates InRelease
Hit:4 http://archive.ubuntu.com/ubuntu noble-backports InRelease
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
2 packages can be upgraded. Run 'apt list --upgradable' to see them.
$ apt list --upgradable
Listing... Done
libaudit-common/noble-updates 1:3.1.2-2.1ubuntu0.1 all [upgradable from: 1:3.1.2-2.1build1.1]
libaudit1/noble-updates 1:3.1.2-2.1ubuntu0.1 amd64 [upgradable from: 1:3.1.2-2.1build1.1]
$ sudo apt install -y tree ufw
…
The following NEW packages will be installed:
  iptables libip4tc2 libip6tc2 libjansson4 libnetfilter-conntrack3
  libnfnetlink0 libnftables1 libnftnl11 nftables tree ufw
0 upgraded, 11 newly installed, 0 to remove and 2 not upgraded.
…
Setting up tree (2.1.1-2ubuntu3.24.04.2) ...
Setting up ufw (0.36.2-6) ...
…
$ tree -a /srv/app
/srv/app
├── .env
└── public
    └── index.html

2 directories, 2 files
$ apt policy tree
tree:
  Installed: 2.1.1-2ubuntu3.24.04.2
  Candidate: 2.1.1-2ubuntu3.24.04.2
  Version table:
 *** 2.1.1-2ubuntu3.24.04.2 500
        500 http://archive.ubuntu.com/ubuntu noble-updates/universe amd64 Packages
        100 /var/lib/dpkg/status
     2.1.1-2ubuntu3 500
        500 http://archive.ubuntu.com/ubuntu noble/universe amd64 Packages
$ sudo apt remove -y tree
…
The following packages will be REMOVED:
  tree
…
Removing tree (2.1.1-2ubuntu3.24.04.2) ...
$ grep -E " (install|remove) tree" /var/log/dpkg.log
2026-10-12 01:40:02 install tree:amd64 <none> 2.1.1-2ubuntu3.24.04.2
2026-10-12 01:40:04 remove tree:amd64 2.1.1-2ubuntu3.24.04.2 <none>
Real output, long installation fragments shortened (…). Every install and removal is logged to /var/log/dpkg.log.

The ufw firewall is disabled on Ubuntu initially. Order matters: allow SSH first, then enable — otherwise new connections to the server become impossible. The OpenSSH profile opens port 22/tcp. All other incoming connections are refused once it is enabled: in our check a test server on 0.0.0.0:8090 stopped answering from outside (curl timed out).

terminal
$ sudo ufw allow OpenSSH
Rules updated
Rules updated (v6)
$ sudo ufw enable
Command may disrupt existing ssh connections. Proceed with operation (y|n)? y
Firewall is active and enabled on system startup
$ sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), deny (routed)
New profiles: skip

To                         Action      From
--                         ------      ----
22/tcp (OpenSSH)           ALLOW IN    Anywhere                  
22/tcp (OpenSSH (v6))      ALLOW IN    Anywhere (v6)             
ufw 0.36.2, real output; the rule was added before enabling the firewall.

The last step is to forbid password and root logins. SSH server settings are read from /etc/ssh/sshd_config, which on Ubuntu first includes the files from /etc/ssh/sshd_config.d/. For sshd the first value found wins, so give your file a low number — it will be read before files the provider or cloud-init may have added.

/etc/ssh/sshd_config.d/01-hardening.conf
# Key-only login; root cannot log in over SSH
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
File permissions 644, owned by root.
terminal
$ sudo nano /etc/ssh/sshd_config.d/01-hardening.conf
$ echo 'PasswordAuthentification no' | sudo tee /etc/ssh/sshd_config.d/02-typo.conf
PasswordAuthentification no
$ sudo sshd -t
/etc/ssh/sshd_config.d/02-typo.conf: line 1: Bad configuration option: PasswordAuthentification
/etc/ssh/sshd_config.d/02-typo.conf: terminating, 1 bad configuration options
$ sudo rm /etc/ssh/sshd_config.d/02-typo.conf
$ sudo sshd -t && echo "config OK"
config OK
$ sudo sshd -T | grep -E '^(passwordauthentication|permitrootlogin|kbdinteractiveauthentication|pubkeyauthentication) '
permitrootlogin no
pubkeyauthentication yes
passwordauthentication no
kbdinteractiveauthentication no
$ sudo systemctl restart ssh.service
$ systemctl is-active ssh.socket ssh.service
active
active
deploy session, real output. The typo is added on purpose to show how sshd -t catches an error before the restart.
terminal
$ ssh -o PubkeyAuthentication=no myvps
deploy@203.0.113.10: Permission denied (publickey).
$ ssh root@203.0.113.10
root@203.0.113.10: Permission denied (publickey).
$ ssh myvps hostname
vps
Check from the laptop in a new window: passwords are no longer accepted, root cannot log in even with a key, deploy key login works.

sshd -T prints the effective settings — that is how you confirm the file was applied. On Ubuntu 24.04 port 22 is held by ssh.socket, so look up port changes in the documentation for this exact version: with socket activation the steps differ from guides for older releases.

Initial setup checklist and exercises

  • An ed25519 key with a passphrase; the server fingerprint is checked against the provider console
  • A user with sudo is created; key login as that user is tested in a new window
  • In sshd_config.d: PasswordAuthentication no, PermitRootLogin no; sudo sshd -t reports no errors
  • ufw: OpenSSH allowed, then enabled; sudo ufw status verbose shows the rules
  • sudo apt update and sudo apt upgrade are done
  • The project lives in /srv/app, owned by the service user, .env with 600
  • The app runs as a systemd service with Restart=on-failure, enabled via enable, listening on 127.0.0.1
  • You know where to look: journalctl -u <service>, /var/log/auth.log, ss -tlnp

Next step — Docker: the same server, but the app is packaged into an image with all its dependencies and starts with one command. The skills from this lesson — users, permissions, ports and logs — are needed inside containers too.

How this was verified14 · passedchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.