You have spent fifty-two lessons administering srv-tramontana. You know how to set up a reverse proxy with TLS, tune PostgreSQL, encrypt a volume, harden a systemd unit and rebuild the whole machine with Ansible in fifty minutes. And there is a reasonable doubt that almost everybody has by this point: how much of this is "enterprise server stuff" and how much really applies anywhere?

This lesson answers that question by building something that is yours: a media server for your home. You are going to set up Jellyfin on your own hardware, with redundant storage, hardware acceleration for transcoding, shares for the family's devices, access from outside and backups. And you are going to discover that it is exactly the same work: the same UUIDs in fstab, the same key in keyrings, the same hardened unit, the same smartctl, the same Ansible.

With two differences that matter at home and do not in the data centre: electricity consumption and noise. And with a legal warning that is worth reading before you start.

Contents

  1. Legal warning: what content is legitimate
  2. Objective, requirements and design decisions
  3. Choosing the hardware
  4. Storage: RAID, Btrfs and the truth about backups
  5. Jellyfin versus Plex and Emby
  6. Installation and a hardened systemd unit
  7. Hardware acceleration for transcoding
  8. Organising the library and naming files
  9. Sharing: Samba for everything, NFS for Linux
  10. Access from outside the house: three options and their risk
  11. Downloads and automation
  12. Backups of the library and of the configuration
  13. Consumption, noise and temperature
  14. Automation with Ansible
  15. Operation: updates, disk health and what to do when one fails

Legal warning: what content is legitimate

It comes first because it is the first thing.

A media server is a neutral tool: it serves the files you give it. What determines its legality is the origin of the content, and in Spain the situation is reasonably clear:

Content Situation
Photos and videos you have recorded yourself Yours, without any doubt
A copy of a DVD or Blu-ray you have bought Grey area: private copying exists, but circumventing the copy protection is prohibited
Music from a CD you have bought, ripped Private copy, generally accepted
Content bought in digital stores without DRM Yours, according to the licence
Works in the public domain or under a free licence Legitimate
Downloads of protected works from P2P networks Not legitimate
Content from a subscription, downloaded by circumventing the DRM Not legitimate

This course does not at any point cover obtaining content protected by copyright. Everything that follows assumes the library contains your own material, legally acquired, or free material. If you have 4 TB of family videos, discs you have bought and classic cinema in the public domain, this project is exactly for you.

And an additional note: if you share the server with people outside your household, even with content you have bought, you are engaging in public communication, which is a different thing from private copying.

Objective, requirements and design decisions

Objective. A home server that serves the family library to televisions, phones, tablets and laptops inside the house; reachable from outside securely; with storage that tolerates the failure of one disk; with backups of what is irreplaceable; and with an electricity bill that does not hurt.

Design decisions, taken before buying anything:

Decision Choice Why
Operating system Ubuntu Server 24.04 LTS The same as the course: everything you have learned applies
Graphical interface None It consumes RAM and adds attack surface for nothing
Media server Jellyfin Free, no account, no telemetry (section 5)
Storage Btrfs RAID 1 over two disks Checksums, snapshots and flexible expansion
Access from outside VPN (08-04) Zero exposed ports
Internal sharing Samba, and NFS only if there are Linux clients Universal compatibility
Management Ansible Rebuildable like srv-tramontana

And a decision worth taking explicitly: this server is your second lab. Being yours and critical to nobody, it is where you can try things you would not try in production. That is one of the best reasons for building it.

We will call the machine srv-home, with the fixed IP 192.168.1.20 on the home network.

Choosing the hardware

Option Indicative price Consumption Transcoding Storage Noise
Raspberry Pi 5 (8 GB) €80-120 4-8 W Very limited; no hardware H.265 decoding USB, no native SATA Silent
Mini PC with an Intel N100 €130-200 6-15 W Excellent: modern Quick Sync, AV1 1-2 M.2 + SATA Very low
Commercial NAS (Synology, QNAP) €300-700 15-30 W Varies by model 2-4 bays Low
Repurposed PC €0 40-90 W Depends on the graphics Many bays High
Second-hand server €100-300 60-150 W Usually no GPU Many bays Very high

The recommendation is the mini PC with an Intel N100 or similar, and for a reason that becomes visible in the table in section 13: consumption. A server that is switched on 24 hours a day for a year consumes 8,760 hours of its average power. The difference between 10 W and 60 W is 438 kWh a year, some €105 in Spain at €0.24/kWh. In three years, the difference in electricity pays for the whole mini PC and then some.

About the Raspberry Pi 5, which is the most popular option and deserves an honest qualification: it is magnificent for a library of music, photos or video that the clients play directly without conversion. But the Pi 5 removed the hardware H.265 decoder that the Pi 4 had, and it has no video encoder. If any client needs transcoding — an old television, a phone on mobile data, an unsupported format — the Pi will do it in software, with a result that ranges from slow to unusable. If your library is H.264 and your clients are modern, the Pi is perfect; if you have H.265 4K material and an old television, it is not.

About repurposing an old PC: it is free and it is the worst option in the medium term. It consumes a lot, it makes noise, its fans are ten years old and its power supply is the part that fails most in a machine that is permanently switched on. As a lab for learning, perfect. As a permanent server, it works out expensive.

On RAM: 4 GB is enough for Jellyfin serving two or three clients. 8 GB gives headroom for the filesystem cache, which is what makes browsing the library feel fluid.

Storage: RAID, Btrfs and the truth about backups

How much space you really need

Content Typical size
A song in FLAC 30-50 MB
A complete album in FLAC 400-600 MB
A photo from a modern phone 3-8 MB
A RAW photo from a camera 25-45 MB
A 1080p H.264 series episode 1.2-2.5 GB
A 1080p H.265 series episode 400-900 MB
A 1080p H.264 film 4-12 GB
A 1080p H.265 film 2-5 GB
A 4K HDR film 25-60 GB
A complete Blu-ray copy 25-45 GB

A realistic family library — 20 years of photos, the music you have bought, a couple of hundred films and a few series — comes to around 4-8 TB. The practical rule: work out what you think you need and double it, because 4K and RAW photos grow faster than intuition suggests.

Why redundancy is worth it here

In 05-04 you set up LVM without RAID, because srv-tramontana is a VM whose storage is already redundant on the host and because its value is in the backups, not in the disk. At home the situation is the opposite: the disks are physical and they are yours, and there is irreplaceable content — the family photos — that cannot be downloaded or bought again.

Level Disks Usable capacity Tolerates Comment
RAID 0 2+ 100% Nothing It doubles the risk. Not at home
RAID 1 2 50% 1 disk The recommendation: simple and sufficient
RAID 5 3+ (n−1)/n 1 disk Long, risky rebuild with large disks
RAID 6 4+ (n−2)/n 2 disks For 6+ disks
RAID 10 4+ 50% 1 per pair Fast, expensive in disks

RAID 5 with disks of 8 TB or more deserves a warning. Rebuilding the array after changing a disk can take more than a day, during which the other disks work flat out reading every sector. That is precisely the moment when another disk is most likely to fail, and if it does, everything is lost. With two disks and RAID 1, the rebuild is a straightforward copy and the risk is far lower.

mdadm versus Btrfs and ZFS

mdadm + ext4 Btrfs ZFS
In the Ubuntu kernel Yes Yes Yes (DKMS module)
Detects silent corruption No Yes: data and metadata checksums Yes
Repairs it automatically No Yes, with RAID 1 Yes
Snapshots With LVM Native, instantaneous Native
Adding a disk of a different size No Yes Difficult
Transparent compression No Yes (zstd) Yes
RAID 5/6 Solid DO NOT USE: still considered unstable Solid (RAIDZ)
RAM consumption Low Low High: ~1 GB per TB with deduplication
Licence GPL GPL CDDL: not distributed in the kernel

The choice is Btrfs in RAID 1, and the decisive argument is checksumming. A classic RAID protects against the failure of a whole disk, which is noisy and obvious. It does not protect against silent corruption: a bit that flips on the disk through magnetic degradation, a firmware error, a faulty cable. mdadm cannot detect it — it has two different copies and does not know which is the good one — and that corrupted bit propagates into your backups. Btrfs keeps a checksum of every block: it detects the error, knows the other copy is correct and repairs it.

For family photos that are going to sit on a disk for thirty years, that is not a technical detail.

$ sudo apt install btrfs-progs smartmontools

# 1. Check the state of the disks BEFORE using them, even new ones
$ sudo smartctl -a /dev/sda | grep -E 'Model|Power_On_Hours|Reallocated'
Device Model:     WDC WD80EFPX-68C4ZN0
  9 Power_On_Hours          0x0032   100   100   000    Old_age   Always  -  4
  5 Reallocated_Sector_Ct   0x0033   100   100   140    Pre-fail  Always  -  0

# 2. Create the filesystem in RAID 1 (data AND metadata)
$ sudo mkfs.btrfs -L media -d raid1 -m raid1 /dev/sda /dev/sdb
Label:              media
UUID:               8f3a2c11-4d5e-4a91-b7c2-1e9d0f8a3b45
Number of devices:  2
Data,RAID1:  8.00TiB
Metadata,RAID1: 8.00GiB

# 3. Mount by UUID, never by /dev/sdX (05-04)
$ sudo mkdir -p /srv/media
$ blkid /dev/sda | grep -oP 'UUID="\K[^"]+'
8f3a2c11-4d5e-4a91-b7c2-1e9d0f8a3b45
# /etc/fstab
# compress=zstd:3  -> compresses what is compressible; already compressed
#                     video is detected and skipped, so it costs nothing
# noatime          -> do not write the access time on every read:
#                     fewer writes and a longer disk life
# nofail           -> IF THE DISK IS NOT THERE, THE SYSTEM STILL BOOTS.
#                     Without this, a disconnected disk leaves the machine
#                     in emergency mode, with no network and no SSH: you
#                     have to go there with a keyboard and a monitor. At
#                     home, essential.
# x-systemd.device-timeout=10 -> do not wait 90 s for an absent disk
UUID=8f3a2c11-4d5e-4a91-b7c2-1e9d0f8a3b45  /srv/media  btrfs \
  defaults,compress=zstd:3,noatime,nofail,x-systemd.device-timeout=10  0  0
$ sudo mount -a          # ALWAYS after touching fstab (course convention)
$ findmnt /srv/media
TARGET     SOURCE    FSTYPE OPTIONS
/srv/media /dev/sda  btrfs  rw,noatime,compress=zstd:3,nofail

$ sudo btrfs filesystem usage /srv/media
Overall:
    Device size:                  14.55TiB
    Device allocated:              2.02TiB
    Used:                          2.01TiB
    Free (estimated):              6.26TiB      (min: 6.26TiB)
Data,RAID1: Size:1.01TiB, Used:1.00TiB
   /dev/sda	 1.01TiB
   /dev/sdb	 1.01TiB

Notice that Device size shows 14.55 TiB (both disks) but Free estimates 6.26 TiB: in RAID 1 every block is written twice. df -h gives confusing figures with Btrfs; always use btrfs filesystem usage.

The scrub: the operation that justifies Btrfs

# Verifies ALL the checksums and repairs what it can from the other copy
$ sudo btrfs scrub start -B /srv/media
scrub done for 8f3a2c11-4d5e-4a91-b7c2-1e9d0f8a3b45
	Total to scrub: 2.01TiB
	Rate: 178.42MiB/s
	Error summary: csum=2
	  Corrected: 2
	  Uncorrectable: 0

Corrected: 2. Two blocks were corrupt on one disk and Btrfs has repaired them with the good copy from the other. With mdadm those two blocks would still be there, corrupt, and they would probably have ended up in the backups. That is the entire argument, in one line of output.

The scrub is scheduled monthly with a timer, following the pattern from 05-05:

# /etc/systemd/system/btrfs-scrub.timer
[Unit]
Description=Monthly Btrfs scrub of /srv/media

[Timer]
OnCalendar=Sun *-*-01..07 03:00:00   # first Sunday of each month
Persistent=true
RandomizedDelaySec=1h

[Install]
WantedBy=timers.target
# /etc/systemd/system/btrfs-scrub.service
[Unit]
Description=Btrfs scrub

[Service]
Type=oneshot
ExecStart=/usr/bin/btrfs scrub start -B -c idle -n 5 /srv/media
# -c idle -n 5: lowest I/O priority, so as not to disturb anybody who is
# watching a film at three in the morning
Nice=19
IOSchedulingClass=idle

RAID is not a backup

It has to be said in plain words, because it is the most expensive domestic mistake:

RAID protects against the failure of a disk. It protects against nothing else.

Threat Does RAID 1 protect?
A disk breaks Yes
Silent corruption of a block Yes, with Btrfs; no with mdadm
You delete a folder by mistake No: it is deleted from both disks
A file-encrypting attack hits the machine No: it encrypts both disks
The power supply fails and takes the disks with it No
Power surge, fire, flood, theft No
An update that corrupts the filesystem No

Of the seven rows, RAID covers one and a half. That is why section 12 exists.

Jellyfin versus Plex and Emby

Jellyfin Plex Emby
Licence GPL, free Proprietary Proprietary (used to be free)
Requires a cloud account No Yes, even for local use Optional
Telemetry None Yes, and there have been controversies Yes
Paid features None: everything included Plex Pass for HW, mobile, skipping intros Emby Premiere
Hardware acceleration Free Requires a subscription Requires a subscription
TV apps Android TV, webOS, Tizen, Roku, Kodi More, and more polished In between
Works if the internet goes down Yes, completely Authentication can fail Yes
Interface maturity Good, somewhat rougher Excellent Good
Automatic metadata Yes Yes, slightly better Yes

The choice is Jellyfin, and there are three reasons that weigh more than a slightly less polished interface:

  1. It does not depend on anybody. Plex demands an account in its cloud even to watch a film from the sofa. The day Plex changes its business model, raises prices or shuts down — things that have happened to products of this kind — your library carries on working with Jellyfin exactly as before. It is the practical definition of why free software matters, and this lesson is a good place to see it.
  2. Hardware acceleration is free. In Plex and Emby it is a paid feature, and it is precisely the function that decides whether the server works or does not.
  3. No telemetry. What your family watches at home does not leave the house.

Installation and a hardened systemd unit

The official repository with the key in keyrings, exactly as in 05-03 — no apt-key, which is obsolete, and no piping a script into bash:

$ sudo install -m 0755 -d /etc/apt/keyrings
$ curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key | \
      sudo gpg --dearmor -o /etc/apt/keyrings/jellyfin.gpg
$ sudo chmod 0644 /etc/apt/keyrings/jellyfin.gpg

$ cat <<EOF | sudo tee /etc/apt/sources.list.d/jellyfin.sources
Types: deb
URIs: https://repo.jellyfin.org/ubuntu
Suites: noble
Components: main
Architectures: amd64
Signed-By: /etc/apt/keyrings/jellyfin.gpg
EOF

$ sudo apt update && sudo apt install jellyfin
$ systemctl is-active jellyfin
active

The mandatory check that the key is what it claims to be, before trusting the repository:

$ gpg --show-keys /etc/apt/keyrings/jellyfin.gpg | head -2
pub   rsa4096 2018-11-08 [SC]
      04B7DA6B1FE10D5E76D9D5A6F5B2F0D5A8E5D6C3

That fingerprint is checked against the one published in the official documentation. It is a step almost everybody skips, and it is the only real defence against a compromised repository.

The hardened unit

The package ships a working but not very restrictive unit. Following the course's drop-in convention, it is not edited: it is complemented.

$ sudo systemctl edit jellyfin
# /etc/systemd/system/jellyfin.service.d/override.conf
[Service]
# --- Filesystem isolation ---
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
# Only these paths are writable
ReadWritePaths=/var/lib/jellyfin /var/log/jellyfin /var/cache/jellyfin /etc/jellyfin
# The library, READ ONLY: Jellyfin has no reason whatsoever to be able
# to delete your films. It is the most important restriction of all.
ReadOnlyPaths=/srv/media

# --- Privileges ---
NoNewPrivileges=true
PrivateDevices=false            # false: it needs /dev/dri for the GPU
DeviceAllow=/dev/dri/renderD128 rw
DeviceAllow=/dev/dri/card0 rw
CapabilityBoundingSet=
AmbientCapabilities=
RestrictSUIDSGID=true

# --- Kernel and memory ---
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectProc=invisible
MemoryDenyWriteExecute=false    # .NET uses JIT compilation: not possible
LockPersonality=true
RestrictRealtime=true
RestrictNamespaces=true

# --- Network ---
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
IPAddressAllow=localhost 192.168.1.0/24 10.8.0.0/24
IPAddressDeny=any

# --- System calls ---
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources @obsolete
SystemCallArchitectures=native

# --- Resource limits (cgroups, 07-05) ---
# Stops a runaway transcode from locking up the machine
CPUQuota=350%
MemoryMax=3G
IOWeight=50
$ sudo systemctl daemon-reload && sudo systemctl restart jellyfin
$ systemd-analyze security jellyfin
→ Overall exposure level for jellyfin.service: 2.4 OK

From 8.2 (dangerous) to 2.4, in the same league as the 1.6 of tramontana.service. It does not go lower for two legitimate reasons: it needs access to /dev/dri for the GPU, and .NET requires executable memory for its JIT compiler.

ReadOnlyPaths=/srv/media deserves a paragraph. Jellyfin reads the library; it does not write it. With this line, neither a bug in the application nor a remote vulnerability can delete twenty years of photos. It is a one-line defence with enormous value, and it is exactly the same reasoning you applied to tramontana.service in 05-05.

And the check that it works:

$ sudo -u jellyfin touch /srv/media/test
touch: cannot touch '/srv/media/test': Read-only file system

Hardware acceleration for transcoding

What transcoding is and why it matters so much

When the client can play the file as it is, Jellyfin does direct play: it sends the bytes without touching them, at almost zero CPU cost. When it cannot — because the codec, the resolution, the container or the bandwidth do not suit it — Jellyfin transcodes: it decodes the video and encodes it again on the fly.

Transcoding a 1080p H.265 film to H.264 in software consumes several cores at 100% in real time. On a two-core mini PC, that means a single viewer saturates the machine and the video stutters. With hardware acceleration, the same work is done by a dedicated block of the chip at a CPU cost of 5%, and three or four streams can be served at once.

It is the difference between a server that works and one that does not. That is why the hardware has to be checked before it is bought.

Technology Manufacturer Quality Notes
Quick Sync (VAAPI/QSV) Intel iGPU Very good The best consumption/performance ratio
NVENC/NVDEC NVIDIA Very good A limit on simultaneous streams on consumer cards
VAAPI on AMD AMD Good Somewhat more uneven support
V4L2 M2M Raspberry Pi 4 Acceptable Not on the Pi 5
Software (libx264) CPU Best quality Not viable in real time on modest machines

Checking whether the machine has it

$ sudo apt install vainfo intel-media-va-driver-non-free

# 1. Does the render device exist?
$ ls -l /dev/dri/
crw-rw---- 1 root video  226,   0 Aug 18 09:02 card0
crw-rw---- 1 root render 226, 128 Aug 18 09:02 renderD128

# 2. What can it do?
$ vainfo --display drm --device /dev/dri/renderD128 2>/dev/null | \
      grep -E 'Driver version|VAProfileH264High|VAProfileHEVCMain|VAProfileAV1'
vainfo: Driver version: Intel iHD driver for Intel(R) Gen Graphics - 24.1.0
      VAProfileH264High               : VAEntrypointVLD        (decodes)
      VAProfileH264High               : VAEntrypointEncSliceLP (encodes)
      VAProfileHEVCMain               : VAEntrypointVLD
      VAProfileHEVCMain10             : VAEntrypointVLD
      VAProfileAV1Profile0            : VAEntrypointVLD

How to read it: VAEntrypointVLD means hardware decoding; VAEntrypointEncSlice means encoding. You need both to transcode properly. This chip decodes H.264, H.265 (including 10-bit) and AV1, and encodes H.264: perfect for what is needed.

renderD128 belongs to the render group, so the service user has to be in it:

$ sudo usermod -aG render,video jellyfin
$ id jellyfin
uid=996(jellyfin) gid=996(jellyfin) groups=996(jellyfin),44(video),993(render)
$ sudo systemctl restart jellyfin

And this connects with the hardened unit: DeviceAllow=/dev/dri/renderD128 rw is what lets the service, with PrivateDevices restricted, still see the GPU. Without that line, group membership is worth nothing — a detail that causes hours of bafflement.

Configuring and verifying

In the Jellyfin dashboard: Dashboard > Playback > Hardware acceleration = VAAPI, device /dev/dri/renderD128, enabling the decoding of H.264, HEVC, HEVC 10-bit and VP9, and ticking "Enable enhanced hardware decoding" and "Allow encoding in HEVC format".

The real verification is done by playing something that forces the conversion:

# With a playback in progress that is transcoding
$ ps -eo pcpu,comm | grep ffmpeg
  6.2 ffmpeg

$ sudo intel_gpu_top -s 1000 | head -8
 Freq MHz    IRQ RC6     Power W        IMC MiB/s
 req  act        %       gpu   pkg     rd     wr
 850  842   1284  12    3.21  8.94   412.1  188.4

 ENGINES     BUSY                     MI_SEM MI_WAIT
 Render/3D   4.12% |█                |      0%      0%
 Video       78.44% |███████████████ |      0%      0%
 VideoEnhance 2.10% |                |      0%      0%

Video 78.44% with ffmpeg at 6.2% CPU: the GPU is doing the work. If ffmpeg appeared at 190% CPU and the video engine at 0%, the acceleration would not be working despite being ticked in the dashboard.

The comparison, measured:

In software With VAAPI
ffmpeg CPU (1080p H.265→H.264) 185% 6%
Simultaneous streams possible 1, with stutters 3-4
Electricity consumption during the conversion +35 W +7 W
Visual quality at the same bit rate Slightly better Slightly worse

And the best optimisation: not transcoding

Everything above is the safety net. The right strategy is for the conversion not to be needed:

  • Store the material as H.264 in an MP4 container if your clients are varied: that is what everything plays directly.
  • Use H.265 only if all your devices support it; it saves half the space.
  • Embedded subtitles force transcoding even if the video is compatible: graphical formats (Blu-ray PGS) mean they have to be "burned" onto the image. External .srt subtitles are sent separately and force nothing. It is the number one cause of unexpected conversions.
  • The audio is usually the hidden culprit: a DTS-HD track the television does not understand forces the audio to be converted, even if the video passes through directly.

Organising the library and naming files

Jellyfin identifies content by querying public metadata databases, and to get it right it needs names it can interpret. With the correct structure, recognition is around 99%; with free-form names, you spend hours correcting things by hand.

/srv/media/
├── movies/
│   ├── The Third Man (1949)/
│   │   ├── The Third Man (1949) - 1080p.mkv
│   │   ├── The Third Man (1949).en.srt
│   │   └── poster.jpg
│   └── Metropolis (1927)/
│       └── Metropolis (1927) - 1080p.mkv
├── series/
│   └── Series Name (2019)/
│       ├── Season 01/
│       │   ├── Series Name S01E01.mkv
│       │   └── Series Name S01E02.mkv
│       └── Season 02/
│           └── Series Name S02E01.mkv
├── music/
│   └── Artist/
│       └── Album (2004)/
│           ├── 01 - First Track.flac
│           └── 02 - Second Track.flac
└── photos/
    └── 2026/
        └── 2026-07 Pyrenees Holiday/
Rule Correct example Problematic example
One folder per film, with the year The Third Man (1949)/ movies/thirdman.mkv
The year in brackets Metropolis (1927) Metropolis 1927
Series with SxxExx Series S01E03.mkv Series 1x3.mkv
Season NN folders Season 01/ Series 1/
Subtitles with a language code film.en.srt subs.srt
Music: a folder per artist and album Artist/Album (2004)/ Everything in one directory

The year is the most important part: it distinguishes between remakes and films with the same title, which is the origin of most misidentifications.

And a practical recommendation: do not rename by hand. filebot (commercial) or tinyMediaManager (free) do the job. Or, with the tools from Module 3:

# See what it would do before doing it (the course's --dry-run convention)
$ for f in /srv/media/incoming/*.mkv; do
      new="$(basename "$f" | sed -E 's/\.(1080p|720p|x264|WEB-DL)//gI; s/\./ /g')"
      printf '%s\n  -> %s\n' "$f" "$new"
  done

Permissions, following the group model from 05-01:

$ sudo groupadd -f media
$ sudo usermod -aG media jellyfin
$ sudo usermod -aG media "$USER"
$ sudo chown -R root:media /srv/media
$ sudo find /srv/media -type d -exec chmod 2775 {} +   # SGID: inherits group
$ sudo find /srv/media -type f -exec chmod 0664 {} +

The SGID bit on the directories (the 2 in 2775) makes everything created inside inherit the media group, without depending on the umask of whoever creates it. It is the same mechanism from 05-02.

Sharing: Samba for everything, NFS for Linux

Jellyfin serves the playback, but you also need access to the files: copying photos from a phone, adding content from a laptop, making backups.

Samba (SMB) NFS
Clients Windows, macOS, Linux, Android, iOS, TV Linux and Unix, mainly
Authentication Its own username and password By the machine's IP (NFSv3) or Kerberos (v4)
Performance on the LAN Very good Slightly better, lower latency
Unix permissions Emulated Native
Configuration Medium Simple, if you trust the network
Encryption Yes, SMB3 Only with Kerberos or over a VPN
Home use The default option For mounting on other Linux machines

The recommendation: Samba as the base, because a phone, a television and a Windows machine speak it without installing anything, and NFS only if you have Linux clients that are going to mount the library permanently.

# /etc/samba/smb.conf (after copying the original to .bak-2026-08-18)
[global]
    workgroup = HOME
    server string = Media server
    security = user
    map to guest = never                  # NO guests at all

    # SMB1 is broken by design (WannaCry spread through it).
    # SMB2 as a minimum; SMB3 adds encryption.
    server min protocol = SMB2
    client min protocol = SMB2
    server smb encrypt = desired

    # Listen ONLY on the LAN and the VPN, never on every interface
    interfaces = 127.0.0.1 192.168.1.0/24 10.8.0.0/24
    bind interfaces only = yes
    hosts allow = 127.0.0.1 192.168.1.0/24 10.8.0.0/24
    hosts deny = 0.0.0.0/0

    # No printers: it removes attack surface and log noise
    load printers = no
    printing = bsd
    printcap name = /dev/null
    disable spoolss = yes

    log level = 1
    log file = /var/log/samba/log.%m
    max log size = 1000

[media]
    path = /srv/media
    comment = Family library
    browseable = yes
    read only = yes                       # by default, READ ONLY
    # Only these users can write
    write list = @media
    valid users = @media
    create mask = 0664
    directory mask = 2775
    force group = media
    vfs objects = recycle                 # recycle bin: saves you from deletions
    recycle:repository = .recycle
    recycle:keeptree = yes
    recycle:versions = yes

[photos]
    path = /srv/media/photos
    valid users = @media
    read only = no
    create mask = 0664
    directory mask = 2775
    force group = media
$ sudo apt install samba
$ testparm -s >/dev/null && echo "valid configuration"   # Samba's nginx -t
valid configuration

# Samba users are INDEPENDENT of the system ones, even when the
# name is the same. They have to be created explicitly.
$ sudo smbpasswd -a marta
New SMB password: ********
$ sudo smbpasswd -e marta

$ sudo systemctl restart smbd nmbd
$ smbclient -L //192.168.1.20 -U marta
	Sharename       Type      Comment
	---------       ----      -------
	media           Disk      Family library
	photos          Disk

Three defensible decisions in that configuration: map to guest = never (anonymous access is the cause of most Samba incidents), read only = yes by default with an explicit write list, and the recycle bin, which has saved more family photos than any other option in this file.

And NFS, for the Linux laptop:

$ sudo apt install nfs-kernel-server
$ echo '/srv/media 192.168.1.0/24(ro,sync,no_subtree_check,root_squash)' | \
      sudo tee -a /etc/exports
$ sudo exportfs -ra
$ sudo exportfs -v
/srv/media    192.168.1.0/24(ro,sync,no_subtree_check,root_squash)

root_squash turns the client's root into nobody: without it, anybody with root on a laptop on the network would have root over your files. And ro, because Samba with real authentication is already there for writing.

And the firewall, following 06-03 — the LAN only:

$ sudo ufw allow from 192.168.1.0/24 to any app Samba
$ sudo ufw allow from 192.168.1.0/24 to any port 8096 proto tcp comment 'Jellyfin'
$ sudo ufw allow from 192.168.1.0/24 to any port 2049 proto tcp comment 'NFS'
$ sudo ufw status | head -6
Status: active
To                         Action      From
--                         ------      ----
Samba                      ALLOW       192.168.1.0/24
8096/tcp (Jellyfin)        ALLOW       192.168.1.0/24
2049/tcp (NFS)             ALLOW       192.168.1.0/24

Access from outside the house: three options and their risk

You want to watch your library from a hotel. There are three ways, and they are not equivalent.

Expose with a reverse proxy VPN Reverse tunnel
Ports open on the router 80 and 443 One UDP None
Attack surface Jellyfin, exposed to the internet The tunnel, with no TCP port Depends on the provider
Requires a public IP Yes Yes No: it works behind CGNAT
Configuration on each device No Yes, once Depends
A Jellyfin flaw compromises you Yes No: it is not reachable Yes
Access to other services at home Only the published one All of them Only the published one
Complexity Medium (08-01) Medium (08-04) Low
Dependence on third parties None None High

The recommendation is the VPN, and it is the next lesson. The reasoning:

Exposing Jellyfin to the internet means that any Jellyfin vulnerability is a vulnerability of your house. It is a large application, with its own authentication and a history of security flaws like any software of its size. Automated scanners find an open port 443 within hours. With the VPN, an attacker who does not have your private key cannot even check whether Jellyfin exists.

And there is an additional argument that usually settles the matter: with the VPN you also reach Samba, the router's admin panel and anything else you set up later, without publishing anything new.

If you decide to expose it anyway — there is a legitimate case: sharing with relatives who are not going to install a VPN — the non-negotiable minimum is:

# /etc/nginx/sites-available/jellyfin — only if you decide to expose it
server {
    listen 443 ssl;
    http2 on;
    server_name media.mydomain.example;

    ssl_certificate     /etc/letsencrypt/live/media.mydomain.example/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/media.mydomain.example/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    add_header Strict-Transport-Security "max-age=63072000" always;
    add_header X-Content-Type-Options "nosniff" always;

    # Rate limit against brute force on the login form
    limit_req zone=login burst=5 nodelay;
    client_max_body_size 20M;

    location / {
        proxy_pass http://127.0.0.1:8096;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        # WebSocket: Jellyfin uses it for the playback state
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        # No buffering: this is streaming video
        proxy_buffering off;
    }
}

It is literally the configuration from 08-01 with two additions — WebSocket and proxy_buffering off — and it demonstrates the point of this lesson: the work is the same. On top of that, fail2ban with a filter for the Jellyfin log, and strong passwords for every account, including the child's.

Downloads and automation

Briefly, because it is an area where the technical and the legal cross over.

There is an ecosystem known as arr — Sonarr, Radarr, Lidarr, Prowlarr — that automates the searching, downloading and organising of content. It is usually deployed with Docker Compose, with what you know from 07-05:

# ~/media/compose.yml (illustrative fragment)
services:
  sonarr:
    image: lscr.io/linuxserver/sonarr:latest
    environment:
      - PUID=1000
      - PGID=1002          # the media group
      - TZ=Europe/Madrid
    volumes:
      - ./sonarr-config:/config
      - /srv/media/series:/series
    ports:
      - "127.0.0.1:8989:8989"   # localhost ONLY: you reach it over the VPN
    restart: unless-stopped

Two observations that apply regardless of the use:

  • ports: "127.0.0.1:8989:8989" is the critical detail. Docker creates its own iptables rules and bypasses ufw, as you saw in 07-05: publishing plain 8989:8989 exposes the port to the whole network even though ufw says otherwise. Specifying the address avoids it.
  • These tools are perfectly legitimate for managing your own content, replacing a damaged file with your own backup, or downloading freely licensed material. Using them to obtain protected works is not, and this course does not cover that part.

A useful and unambiguous domestic use: the automatic backup of the photos on your phone. Immich or Nextcloud, in Docker, replace the cloud photo service with one of your own, and they are perhaps the best argument for having a server at home.

Backups of the library and of the configuration

Here 05-08 applies exactly, and there is a distinction to be made that saves money:

Content Irreplaceable Strategy
Family photos and videos Yes, absolutely Full 3-2-1, off site
Scanned documents Yes Full 3-2-1
Jellyfin configuration (users, watched state, lists) Expensive to redo A daily, small backup
Music ripped from your own CDs Recoverable with effort A local copy; off site optional
Films from your own discs Recoverable with a lot of effort RAID only; a copy if there is spare space

Copying 6 TB of films to the cloud costs money every month and does not add much, because the original discs are still on the shelf. Copying 400 GB of family photos off site is non-negotiable, and it costs little. Making that distinction is the decision that makes the project sustainable.

#!/usr/bin/env bash
#
# home_backup.sh - Backups of the home media server
#
# Strategy:
#   - Irreplaceable (photos, documents) -> restic, off site
#   - Jellyfin configuration            -> restic, daily
#   - Films and music                   -> local Btrfs snapshot
#
set -euo pipefail

readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/lib/common.sh"

readonly HOME_MEDIA="${HOME_MEDIA:-/srv/media}"
readonly HOME_SNAP="${HOME_SNAP:-/srv/media/.snapshots}"
export RESTIC_REPOSITORY="${RESTIC_REPOSITORY:-b2:mydomain-media:/}"

umask 077

local_snapshot() {
    local name="${HOME_SNAP}/$(date +%F)"
    [[ -d "$name" ]] && { log "today's snapshot already exists"; return 0; }
    # Read-only snapshot: it takes no space until something changes
    btrfs subvolume snapshot -r "$HOME_MEDIA" "$name" \
        || die 73 "could not create the snapshot"
    log "snapshot created: $name"

    # Retention: keep 7 daily ones
    local surplus
    mapfile -t surplus < <(find "$HOME_SNAP" -maxdepth 1 -type d -name '20*' \
        | sort -r | tail -n +8)
    local s
    for s in "${surplus[@]:-}"; do
        [[ -n "$s" ]] || continue
        btrfs subvolume delete "$s" && log "old snapshot deleted: $s"
    done
}

offsite_backup() {
    require_command restic
    RESTIC_PASSWORD="$(pass home/restic)" \
    restic backup \
        "${HOME_MEDIA}/photos" \
        "${HOME_MEDIA}/documents" \
        /var/lib/jellyfin \
        /etc/jellyfin \
        --exclude-caches \
        --exclude '*/metadata/*' \
        --tag home \
        || die 74 "restic failed"

    RESTIC_PASSWORD="$(pass home/restic)" \
    restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --prune
}

verify() {
    # An unverified backup is not a backup (05-08)
    RESTIC_PASSWORD="$(pass home/restic)" \
    restic check --read-data-subset=2% || die 74 "the restic verification failed"
    log "verification correct"
}

main() {
    require_command btrfs
    local_snapshot
    offsite_backup
    verify
    log "backup complete"
}

main "$@"

Btrfs snapshots deserve an explanation, because they are the protection against human error that RAID does not give you. A snapshot is an instantaneous copy of the current state that takes up no space: it shares every block with the original and only diverges when something changes. If tomorrow you delete a photo folder by mistake, it is in /srv/media/.snapshots/2026-08-18/ without having cost a single byte.

$ sudo btrfs subvolume list /srv/media
ID 258 gen 4412 top level 5 path .snapshots/2026-08-16
ID 259 gen 4488 top level 5 path .snapshots/2026-08-17
ID 260 gen 4501 top level 5 path .snapshots/2026-08-18

# Recovering something deleted by mistake: it is a simple copy
$ sudo cp -a /srv/media/.snapshots/2026-08-17/photos/2026-07 /srv/media/photos/

And the warning: a snapshot is not a backup. It lives on the same disks. It protects against human error, not against a hardware failure or a fire. That is why the script does both things.

Consumption, noise and temperature

Three factors that in a data centre are somebody else's problem and in your living room are yours.

The annual electricity cost

Annual cost = Average power (W) x 8760 h / 1000 x the price of the kWh
Machine Average power kWh/year Cost at €0.24/kWh
Raspberry Pi 5 + 1 SSD 7 W 61 €15
Mini PC N100 + 2 HDDs (spun down) 13 W 114 €27
Mini PC N100 + 2 HDDs (active) 24 W 210 €50
A 4-bay NAS 28 W 245 €59
Repurposed PC 65 W 569 €137
Second-hand server 110 W 964 €231

The difference between the mini PC and the repurposed PC is €110 a year. That is the argument from section 3 turned into money.

$ sudo apt install powertop lm-sensors
$ sudo sensors-detect --auto >/dev/null

# Calibrate first (it takes a few minutes and measures each state)
$ sudo powertop --calibrate
$ sudo powertop --auto-tune       # applies the recommended settings

$ sudo powertop --time=60 --csv=/tmp/power.csv >/dev/null
$ grep -A6 'Power est' /tmp/power.csv | head -8
The battery reports a discharge rate of 12.4 W
Usage       Events/s   Category       Description
  6.2 ms/s     14.1     Process        ffmpeg
  1.1 ms/s      8.4     Process        jellyfin

powertop --auto-tune enables the power saving of USB, PCIe and SATA. A warning: it can switch off USB ports that are in occasional use. To make it persistent, a systemd service, not an rc.local:

# /etc/systemd/system/powertop.service
[Unit]
Description=PowerTOP power-saving settings
After=multi-user.target

[Service]
Type=oneshot
ExecStart=/usr/sbin/powertop --auto-tune
RemainAfterExit=true

[Install]
WantedBy=multi-user.target

Spinning the disks down when they are not in use

A 3.5-inch HDD consumes 6-9 W spinning and 0.5 W stopped. With two disks, spinning them down when nobody is watching anything saves some €30 a year — and, above all, it reduces the background noise.

$ sudo apt install hdparm

# -B 127: aggressive power management but WITHOUT parking the head every
#         other minute (values < 128 allow spin-down; 254 disables it)
# -S 120: stop after 120 x 5 s = 10 minutes of inactivity
$ sudo hdparm -B 127 -S 120 /dev/sda
# /etc/hdparm.conf — persistent across reboots
/dev/disk/by-id/ata-WDC_WD80EFPX-68C4ZN0_WD-CA0J1234 {
    apm = 127
    spindown_time = 120
}

The trade-off has to be understood: every start from stopped wears the disk more than several hours of spinning, and it adds 5-8 seconds of waiting when the library is opened. With a spin-down time of 10 minutes and normal use in the evenings, the balance is favourable. With 5 minutes and somebody browsing the library on and off, the disk starts and stops twenty times a night, and that shortens its life. Never go below 10 minutes.

/dev/disk/by-id/ is used and not /dev/sda for the same reason as UUIDs in fstab: the sdX names change order between boots.

Temperature

$ sensors
coretemp-isa-0000
Package id 0:  +42.0°C  (high = +100.0°C, crit = +100.0°C)

$ sudo smartctl -A /dev/sda | grep -i temperature
194 Temperature_Celsius     0x0022   118   105   000    Old_age   Always  -  38
Component Ideal Acceptable Worrying
CPU at idle < 45 °C < 60 °C > 75 °C
CPU transcoding < 70 °C < 85 °C > 90 °C
HDD 30-40 °C 25-45 °C > 50 °C or < 20 °C
NVMe SSD < 55 °C < 70 °C > 75 °C

The large-scale reliability studies are clear: hard disks last less above 45 °C and also below 20 °C. In a closed cabinet in the living room, with no ventilation, two disks go past 50 °C easily. A 120 mm fan at low revolutions is practically inaudible and brings the temperature down by 8-10 °C.

Automation with Ansible

# ~/home-infra/roles/media/defaults/main.yml
---
media_mount_point: /srv/media
media_uuid: 8f3a2c11-4d5e-4a91-b7c2-1e9d0f8a3b45
media_group: media
media_gid: 1002
media_lan_net: 192.168.1.0/24
media_vpn_net: 10.8.0.0/24
media_samba_users: [marta, luis]
media_hw_accel: true
media_gpu_device: /dev/dri/renderD128
media_hdparm_spindown: 120
media_subdirs: [movies, series, music, photos, documents]
# ~/home-infra/roles/media/tasks/main.yml
---
- name: Install the base packages
  ansible.builtin.apt:
    name:
      - btrfs-progs
      - smartmontools
      - samba
      - hdparm
      - powertop
      - restic
    state: present
    update_cache: true
  tags: [packages]

- name: Hardware acceleration packages
  ansible.builtin.apt:
    name: [vainfo, intel-media-va-driver-non-free]
    state: present
  when: media_hw_accel | bool
  tags: [gpu]

- name: Jellyfin repository key in keyrings
  ansible.builtin.get_url:
    url: https://repo.jellyfin.org/jellyfin_team.gpg.key
    dest: /etc/apt/keyrings/jellyfin.asc
    mode: '0644'
  tags: [packages]

- name: Jellyfin repository
  ansible.builtin.deb822_repository:
    name: jellyfin
    types: deb
    uris: https://repo.jellyfin.org/ubuntu
    suites: "{{ ansible_distribution_release }}"
    components: main
    architectures: amd64
    signed_by: /etc/apt/keyrings/jellyfin.asc
  register: jellyfin_repo
  tags: [packages]

- name: Install Jellyfin
  ansible.builtin.apt:
    name: jellyfin
    state: present
    update_cache: "{{ jellyfin_repo.changed }}"
  tags: [packages]

# --- Storage ---
- name: Check that the filesystem exists before mounting it
  ansible.builtin.command: "blkid -U {{ media_uuid }}"
  register: blk
  changed_when: false
  failed_when: blk.rc != 0

- name: Mount the library by UUID with nofail
  ansible.posix.mount:
    path: "{{ media_mount_point }}"
    src: "UUID={{ media_uuid }}"
    fstype: btrfs
    opts: defaults,compress=zstd:3,noatime,nofail,x-systemd.device-timeout=10
    state: mounted

- name: The library group
  ansible.builtin.group:
    name: "{{ media_group }}"
    gid: "{{ media_gid }}"
    state: present

- name: Directory structure with SGID
  ansible.builtin.file:
    path: "{{ media_mount_point }}/{{ item }}"
    state: directory
    owner: root
    group: "{{ media_group }}"
    mode: '2775'
  loop: "{{ media_subdirs }}"

- name: Add jellyfin to the necessary groups
  ansible.builtin.user:
    name: jellyfin
    groups: "{{ [media_group] + (['render', 'video'] if media_hw_accel else []) }}"
    append: true
  notify: Restart jellyfin

- name: Hardening drop-in for jellyfin
  ansible.builtin.template:
    src: jellyfin-override.conf.j2
    dest: /etc/systemd/system/jellyfin.service.d/override.conf
    owner: root
    group: root
    mode: '0644'
  notify:
    - Reload systemd
    - Restart jellyfin

- name: Samba configuration
  ansible.builtin.template:
    src: smb.conf.j2
    dest: /etc/samba/smb.conf
    owner: root
    group: root
    mode: '0644'
    backup: true
    validate: 'testparm -s %s'    # Samba's nginx -t (08-01)
  notify: Restart samba

- name: Firewall - the LAN and the VPN only
  community.general.ufw:
    rule: allow
    src: "{{ item.0 }}"
    port: "{{ item.1 }}"
    proto: tcp
  loop: "{{ [media_lan_net, media_vpn_net] | product(['445', '8096']) | list }}"
  tags: [firewall]

- name: Scrub, SMART and backup timers
  ansible.builtin.copy:
    src: "{{ item }}"
    dest: "/etc/systemd/system/{{ item }}"
    mode: '0644'
  loop:
    - btrfs-scrub.service
    - btrfs-scrub.timer
    - home-backup.service
    - home-backup.timer
  notify: Reload systemd

- name: Enable the timers
  ansible.builtin.systemd:
    name: "{{ item }}"
    enabled: true
    state: started
    daemon_reload: true
  loop: [btrfs-scrub.timer, home-backup.timer, smartd.service]

# --- Verification ---
- name: Verify that hardware acceleration is visible
  ansible.builtin.command: "vainfo --display drm --device {{ media_gpu_device }}"
  register: va
  changed_when: false
  failed_when: "'VAProfileH264High' not in va.stdout"
  when: media_hw_accel | bool
  tags: [verify]

- name: Verify that Jellyfin responds
  ansible.builtin.uri:
    url: "http://127.0.0.1:8096/health"
    status_code: 200
  retries: 5
  delay: 3
  tags: [verify]

That validate: 'testparm -s %s' is the same pattern as nginx -t -c %s in 08-01, and it protects against the same problem: an invalid configuration that stops the service from starting. The pattern repeats because it is correct, not by coincidence.

Operation: updates, disk health and what to do when one fails

Updates

# Unattended security updates (06-06)
$ sudo apt install unattended-upgrades
$ sudo dpkg-reconfigure -plow unattended-upgrades

With one difference from srv-tramontana: here it is worth allowing the automatic reboot, because there is no maintenance window to negotiate and nobody to warn.

# /etc/apt/apt.conf.d/50unattended-upgrades
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "05:30";
Unattended-Upgrade::Automatic-Reboot-WithUsers "false";

Automatic-Reboot-WithUsers "false" avoids rebooting if somebody is logged in — but it does not detect somebody watching a film, so 05:30 is a deliberate choice.

Jellyfin is updated with apt like any other package. Before a major update, take a snapshot:

$ sudo btrfs subvolume snapshot -r /var/lib/jellyfin \
      /srv/media/.snapshots/jellyfin-pre-$(date +%F)

Disk health

$ sudo systemctl enable --now smartd

# A short test weekly, a long one monthly, and an email alert
$ sudo tee /etc/smartd.conf <<'EOF'
DEVICESCAN -a -o on -S on -n standby,q \
  -s (S/../.././02|L/../(01|15)/./03) \
  -W 4,45,50 \
  -m root -M exec /usr/share/smartmontools/smartd-runner
EOF
$ sudo systemctl restart smartd
Parameter Meaning
-n standby,q Do not wake a stopped disk in order to check it
-s (S/../.././02|L/../(01|15)/./03) A short test daily at 02:00; a long one on the 1st and 15th at 03:00
-W 4,45,50 Warn if it rises 4 °C suddenly, if it goes past 45 °C, critical at 50 °C

The attributes that really predict a failure, according to the large-scale reliability studies:

$ sudo smartctl -A /dev/sda | \
      awk '$1 ~ /^(5|187|188|197|198)$/ {printf "%-28s %s\n", $2, $10}'
Reallocated_Sector_Ct        0
Reported_Uncorrect           0
Command_Timeout              0
Current_Pending_Sector       0
Offline_Uncorrectable        0
Attribute What it indicates Action threshold
5 Reallocated_Sector_Ct Bad sectors reallocated > 0: watch it. Growing: replace it
197 Current_Pending_Sector Suspect sectors not yet reallocated > 0: replace it soon
198 Offline_Uncorrectable Unreadable sectors > 0: replace it now
187 Reported_Uncorrect Uncorrectable errors > 0: watch it
188 Command_Timeout Commands that timed out Usually the cable, not the disk

A disk with a growing Current_Pending_Sector is dying, even if SMART says PASSED. SMART's overall verdict is notoriously optimistic: look at the attributes, not at the summary.

When a disk fails

# The symptom: errors in the journal
$ sudo journalctl -k --since today | grep -iE 'ata[0-9]|I/O error|medium error'
kernel: ata2.00: exception Emask 0x0 SAct 0x0 SErr 0x0 action 0x0
kernel: blk_update_request: I/O error, dev sdb, sector 1928374656

$ sudo btrfs device stats /srv/media
[/dev/sda].write_io_errs    0
[/dev/sda].read_io_errs     0
[/dev/sda].corruption_errs  0
[/dev/sdb].write_io_errs    142
[/dev/sdb].read_io_errs     2891
[/dev/sdb].corruption_errs  18

Replacement procedure with Btrfs RAID 1:

# 1. DO NOT unmount or power off. With RAID 1 the system keeps working.
#    First, check that there is a backup of what is irreplaceable.
$ RESTIC_PASSWORD=$(pass home/restic) restic snapshots --tag home | tail -3

# 2. If the disk STILL responds: hot replacement (the ideal case).
#    Btrfs copies what it can from the old disk and the rest from the mirror.
$ sudo btrfs replace start -f /dev/sdb /dev/sdc /srv/media
$ sudo btrfs replace status /srv/media
0.8% done, 0 write errs, 0 uncorr. read errs

# 3. If the disk NO LONGER responds: mount degraded and add a new one
$ sudo mount -o degraded,compress=zstd:3 UUID=8f3a2c11-... /srv/media
$ sudo btrfs device add /dev/sdc /srv/media
$ sudo btrfs device remove missing /srv/media
$ sudo btrfs balance start -dconvert=raid1 -mconvert=raid1 /srv/media

# 4. Verify that the redundancy has been restored
$ sudo btrfs filesystem df /srv/media
Data, RAID1: total=1.01TiB, used=1.00TiB
Metadata, RAID1: total=8.00GiB, used=6.12GiB

# 5. A full scrub to confirm that everything is intact
$ sudo btrfs scrub start -B /srv/media

Three warnings about this procedure:

  • btrfs replace is preferable to add + remove: it is faster and it keeps the redundancy for more of the process.
  • Degraded mode has a limit: with a two-disk RAID 1, a second failure during the rebuild loses everything. It is the point of greatest risk in the array's life cycle, and the reason why off-site backups exist.
  • Replace the disk when SMART warns you, not when it fails. A hot replacement of a disk that can still be read is a formality; a rebuild from a dead disk is a risk.

The domestic routine

Frequency Task Tool
Automatic Security updates unattended-upgrades
Automatic Daily backup of the irreplaceable home_backup.sh + timer
Automatic Daily snapshot Btrfs
Daily Short SMART test smartd
Weekly (2 min) Review the warnings and the space btrfs filesystem usage
Monthly (10 min) Btrfs scrub and long SMART test Timers
Quarterly (20 min) Restore a file from the backup restic restore
Annually Check the dust, the fans and the temperatures Physically

The quarterly row is the same as in 08-02 and for the same reason: a backup that has never been restored is a file about which we assume things. At home it is skipped even more often than at work.

Common Mistakes and Tips

  • Believing that RAID is a backup. It protects against the failure of a disk and against nothing else: not deletions, not encrypting malware, not fires.
  • Using Btrfs in RAID 5 or 6. They are still considered unstable. RAID 1, or mdadm if you need parity.
  • Mounting without nofail. A disconnected disk leaves the machine in emergency mode, with no network and no SSH, and you have to go there with a keyboard and a monitor.
  • Mounting by /dev/sdX. The order changes between boots. Always the UUID.
  • Not checking hardware acceleration before buying. It is what decides whether the server works. The Pi 5 has no H.265 decoder.
  • Forgetting DeviceAllow in the hardened unit. The user is in the render group and still cannot see the GPU, and the bafflement lasts hours.
  • Not putting ReadOnlyPaths over the library. It is one line that stops a bug in the application from deleting twenty years of photos.
  • Allowing guests in Samba. map to guest = never. Anonymous access is the cause of most incidents.
  • Leaving SMB1 enabled. It is broken by design; it is how WannaCry spread.
  • NFS without root_squash. Anybody with root on a laptop on the network has root over your files.
  • Publishing ports with Docker believing that ufw covers them. Docker writes its own rules and bypasses them. Use 127.0.0.1:port:port.
  • Exposing Jellyfin directly to the internet. A vulnerability of its own becomes a vulnerability of your house. Use the VPN from 08-04.
  • Setting the disk spin-down time below 10 minutes. They start and stop twenty times a night, and that kills them sooner.
  • Trusting SMART's PASSED verdict. It is optimistic. Look at attributes 5, 197 and 198.
  • Putting the server in a closed cabinet. Above 45 °C, disks last significantly less.
  • Copying 6 TB of films to the cloud. It costs money every month and the original discs are still on the shelf. Copy what is irreplaceable.
  • A tip on method. Treat this server with the same rigour as the one at work — Ansible, verified backups, hardened units — and you will gain two things: a server that lasts, and a lab where you can practise without risk.

Exercises

Exercise 1

A member of the family complains that films "stutter" on the living-room television, but they play perfectly on the tablet. Diagnose the problem from beginning to end and solve it.

Exercise 2

Design the complete backup strategy for a home server with 6 TB in use: 400 GB of photos and documents, 800 GB of music ripped from your own CDs and 4.8 TB of films from discs you have bought. Justify each decision with its cost.

Exercise 3

Write a script home_check.sh following the course's conventions that checks the health of the home server and returns 0, 1 or 2.

Solutions

Solution 1

Step-by-step diagnosis. The starting fact is valuable: the same content plays well on one device and badly on another, so the problem is not in the file or in the disk, but in the relationship between the file and that particular client.

# 1. Is it transcoding? The Jellyfin dashboard says so, and so does this:
$ ps -eo pid,pcpu,args | grep '[f]fmpeg' | head -1
  8814 191.2 /usr/lib/jellyfin-ffmpeg/ffmpeg -i /srv/media/movies/...
    -c:v libx264 -preset veryfast -b:v 3000000 ...

191.2% CPU and -c:v libx264: it is transcoding in software, saturating both cores. That is the immediate cause of the stuttering.

# 2. Why is it transcoding? Analyse the file
$ ffprobe -v error -show_entries stream=index,codec_type,codec_name,width,height,channels \
      -of csv=p=0 "/srv/media/movies/Deep Water (2019)/Deep Water (2019).mkv"
0,video,hevc,3840,2160
1,audio,dts,8
2,subtitle,hdmv_pgs_subtitle

# 3. What does the television support? In the Jellyfin log, after playing:
$ sudo journalctl -u jellyfin --since "10 min ago" | grep -i 'transcod\|reason'
[INF] Transcoding reason: VideoCodecNotSupported, AudioCodecNotSupported,
      SubtitleCodecNotSupported

Three accumulated reasons, and they have to be attacked separately:

Reason Detail Solution
VideoCodecNotSupported HEVC 4K; the television only does H.264 1080p Hardware acceleration or a second version
AudioCodecNotSupported DTS 7.1; the television does AC3 5.1 Transcode the audio only (cheap)
SubtitleCodecNotSupported PGS: a graphical subtitle that has to be burned onto the image Extract it to an external .srt

And the check that rules out the other usual suspect:

# 4. Is it the network? The living-room Wi-Fi versus a cable
$ iperf3 -c 192.168.1.45 -t 10 -R
[  5]   0.00-10.00  sec  58.2 MBytes  48.8 Mbits/sec

48.8 Mbit/s is enough for a 1080p stream (around 10 Mbit/s) but not for 4K without conversion (40-80 Mbit/s). So the network is a real secondary factor: even if we fixed the codecs, 4K over that Wi-Fi would be touch and go.

Resolution, in four measures ordered by benefit/effort ratio:

# --- MEASURE 1: enable hardware acceleration (immediate impact) ---
$ vainfo --display drm --device /dev/dri/renderD128 | grep -c EncSlice
6
$ sudo usermod -aG render,video jellyfin
$ sudo systemctl restart jellyfin
# And in the dashboard: Playback > VAAPI, /dev/dri/renderD128,
# ticking HEVC, HEVC 10-bit, VP9 and "HEVC encoding"

# Verify the effect
$ ps -eo pcpu,args | grep '[f]fmpeg' | awk '{print $1}'
5.8
$ sudo intel_gpu_top -s 1000 | grep Video
 Video      74.12% |██████████████   |

From 191% CPU to 5.8%. The stuttering disappears immediately.

# --- MEASURE 2: extract the subtitles to an external file ---
# Graphical PGS subtitles force the VIDEO to be transcoded even when the
# codec is compatible: they have to be "burned" onto the image. The .srt
# ones are sent separately and the client draws them.
$ ffmpeg -i "Deep Water (2019).mkv" -map 0:s:0 -c:s srt "Deep Water (2019).en.srt"
# --- MEASURE 3: convert only the problematic audio, without touching the video ---
# -c:v copy does not re-encode the video: it is seconds, not hours, and
# with no loss of image quality whatsoever.
$ ffmpeg -i input.mkv -c:v copy -c:s copy \
      -c:a ac3 -b:a 640k -ac 6 output.mkv
# --- MEASURE 4: an Ethernet cable or powerline to the living room ---
$ iperf3 -c 192.168.1.45 -t 10 -R
[  5]   0.00-10.00  sec  1.09 GBytes  938 Mbits/sec

The result, measured before and after:

Metric Before After
ffmpeg CPU 191% 5.8%
Stutters in 30 min of playback 14 0
Simultaneous streams possible 1, with stutters 3-4
Consumption during playback 48 W 17 W
Direct play No Yes, if there is a 1080p H.264 version

And the underlying lesson, which goes beyond this case: the best transcode is the one that does not happen. For a library with heterogeneous clients, the definitive strategy is to keep two versions of the important material — the original 4K HEVC for the modern television and a 1080p H.264 version with AC3 audio for everything else — which Jellyfin offers automatically. It costs 30% more space and eliminates the conversion completely.

Solution 2

The principle that organises the whole strategy: not all content is worth the same. Copying everything with the same criterion is expensive and, paradoxically, usually ends with nothing being copied at all.

Category Volume Can it be recovered? Cost of recovering it Value
Photos and documents 400 GB Never Infinite Maximum
Music from your own CDs 800 GB Yes, by ripping them again ~40 h of work Medium
Films from your own discs 4.8 TB Yes, by copying them again ~200 h of work Low
Jellyfin configuration 2 GB Yes, by reconfiguring ~4 h Medium

A tiered strategy, applying 3-2-1 where it belongs:

TIER 1 - Irreplaceable (402 GB): photos, documents, configuration
  Copy 1: local Btrfs RAID 1 (the original)
  Copy 2: encrypted external USB disk, monthly, in a drawer
  Copy 3: restic in the cloud, daily, encrypted end to end
  -> 3 copies, 2 media, 1 off site. Full 3-2-1.

TIER 2 - Recoverable with effort (800 GB): music
  Copy 1: local Btrfs RAID 1
  Copy 2: the same external USB disk, monthly
  -> No cloud copy: 800 GB a month is not worth 40 h of work.

TIER 3 - Recoverable (4.8 TB): films
  Copy 1: local Btrfs RAID 1
  Copy 2: the original discs on the shelf (you already have it)
  -> No additional copy. RAID covers the failure of one disk and the
     originals cover the rest.

The economic calculation, which is the argument:

Strategy Volume in the cloud Annual cost (at €0.005/GB/month) Comment
Copy everything 6,000 GB €360/year Unsustainable at home
Tier 1 only 402 GB €24/year Recommended
Nothing in the cloud 0 GB €0 A fire takes it all

Twenty-four euros a year protect what is irreplaceable. Three hundred and sixty would also protect some films that are on the shelf. The difference between those two figures is the reason why many people end up with no backup at all: they try to copy everything, see the price and give up.

Implementation:

# --- Tier 1: restic in the cloud, daily ---
$ export RESTIC_REPOSITORY="b2:mydomain-media:/"
$ restic init                    # once only

# The passphrase, in pass (06-05). If it is lost, the backup is
# UNREADABLE: there is no possible recovery. It also goes on paper, at a
# relative's house, inside a sealed envelope.
$ pass generate home/restic 40
# --- Tiers 1 and 2: encrypted external USB disk, monthly ---
# LUKS (06-05): if you lose the disk, the data is not readable
$ sudo cryptsetup luksFormat /dev/sdd1
$ sudo cryptsetup open /dev/sdd1 backup-external
$ sudo mkfs.btrfs -L backup /dev/mapper/backup-external

# The monthly copy. --delete is used WITH CARE: if the source is
# unmounted, rsync would delete the whole destination. The prior check
# is not optional.
$ mountpoint -q /srv/media || { echo "source not mounted; aborting"; exit 1; }
$ sudo rsync -aHAX --delete --info=progress2 \
      /srv/media/{photos,documents,music}/ /mnt/backup/
$ sudo umount /mnt/backup && sudo cryptsetup close backup-external

Verification, without which none of the above counts:

# Automatic, weekly
$ restic check --read-data-subset=5%

# Manual, quarterly: really restore a file and OPEN it
$ restic restore latest --include '/srv/media/photos/2019' --target /tmp/test
$ sha256sum /tmp/test/srv/media/photos/2019/IMG_4412.jpg \
            /srv/media/photos/2019/IMG_4412.jpg
a3f1...  /tmp/test/srv/media/photos/2019/IMG_4412.jpg
a3f1...  /srv/media/photos/2019/IMG_4412.jpg

The recovery table, which is what you have to have written down and kept off the server:

Scenario What is lost Where it is recovered from Time
A disk fails Nothing RAID 1: hot replacement 6-12 h of rebuilding
You delete a folder Nothing The day's Btrfs snapshot 2 minutes
A file becomes corrupt Nothing Btrfs repairs it by itself in the scrub Automatic
Encrypting malware hits the machine Tier 3 Cloud (tier 1) + USB (1 and 2) + original discs 2-3 days
Fire or theft Tiers 2 and 3 Cloud: tier 1 only 1-2 days for 400 GB

That last row is the moment of truth for the whole strategy, and it is honest: in a fire the films and the music are lost. That is consciously accepted, because the original discs would probably burn too and the cost of avoiding it is €336 a year. What matters — the family's photos — survives, which is exactly what it was designed for.

Solution 3

#!/usr/bin/env bash
#
# home_check.sh - Health check of the home media server
#
# Exit codes:
#   0 = correct   1 = warning   2 = critical
#
# Designed to run from a daily timer, silent when all is well:
# only a state != 0 produces a notification.
#
set -euo pipefail

readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/lib/common.sh"

readonly HOME_MEDIA="${HOME_MEDIA:-/srv/media}"
readonly HOME_DISKS="${HOME_DISKS:-/dev/sda /dev/sdb}"
readonly HOME_SPACE_THRESHOLD="${HOME_SPACE_THRESHOLD:-85}"
readonly HOME_TEMP_THRESHOLD="${HOME_TEMP_THRESHOLD:-45}"
readonly HOME_BACKUP_HOURS="${HOME_BACKUP_HOURS:-36}"
readonly HOME_SCRUB_DAYS="${HOME_SCRUB_DAYS:-45}"

umask 027
overall_status=0

report() {
    local level="$1" message="$2"
    case "$level" in
        ok)       log "OK       $message" ;;
        warn)     error "WARN     $message"; (( overall_status < 1 )) && overall_status=1 ;;
        critical) error "CRITICAL $message"; overall_status=2 ;;
    esac
    return 0
}

check_mount() {
    if ! mountpoint -q "$HOME_MEDIA"; then
        report critical "$HOME_MEDIA is NOT mounted"
        return 0
    fi
    report ok "library mounted"

    # With 'nofail', an absent disk does NOT stop the machine booting: the
    # presence of both devices has to be checked explicitly.
    local n
    n="$(btrfs filesystem show "$HOME_MEDIA" | grep -c '^\s*devid')"
    if (( n < 2 )); then
        report critical "only $n device(s): the RAID 1 is DEGRADED"
    else
        report ok "RAID 1 with $n devices"
    fi
}

check_space() {
    mountpoint -q "$HOME_MEDIA" || return 0
    # 'df' lies with Btrfs in RAID: btrfs's own output is used
    local free_b total_b used_pct
    free_b="$(btrfs filesystem usage -b "$HOME_MEDIA" | \
        awk '/Free \(estimated\)/{gsub(/[^0-9]/,"",$3); print $3}')"
    total_b="$(btrfs filesystem usage -b "$HOME_MEDIA" | \
        awk '/Device size/{gsub(/[^0-9]/,"",$3); print $3/2}')"
    used_pct=$(( 100 - (free_b * 100 / total_b) ))

    if (( used_pct >= 95 )); then
        report critical "library at ${used_pct}% ($(format_bytes "$free_b") free)"
    elif (( used_pct >= HOME_SPACE_THRESHOLD )); then
        report warn "library at ${used_pct}%"
    else
        report ok "space at ${used_pct}% ($(format_bytes "$free_b") free)"
    fi
}

check_btrfs_errors() {
    mountpoint -q "$HOME_MEDIA" || return 0
    local total
    total="$(btrfs device stats "$HOME_MEDIA" | awk '{s+=$2} END {print s+0}')"
    if (( total > 0 )); then
        report critical "btrfs has accumulated $total I/O or corruption errors"
        btrfs device stats "$HOME_MEDIA" | awk '$2>0 {print "         " $0}' >&2
    else
        report ok "no I/O or corruption errors"
    fi
}

check_scrub() {
    mountpoint -q "$HOME_MEDIA" || return 0
    local when days
    when="$(btrfs scrub status "$HOME_MEDIA" | \
        awk -F': ' '/Scrub started/{print $2}')" || true
    if [[ -z "${when:-}" ]]; then
        report warn "there is no record of any scrub having run"
        return 0
    fi
    days=$(( ( $(date +%s) - $(date -d "$when" +%s) ) / 86400 ))
    if (( days > HOME_SCRUB_DAYS )); then
        report warn "last scrub $days days ago"
    else
        report ok "last scrub $days days ago"
    fi
}

check_disks() {
    require_command smartctl
    local d
    for d in $HOME_DISKS; do
        [[ -b "$d" ]] || { report critical "$d does not exist"; continue; }

        # -n standby: do NOT wake a stopped disk just to look at it
        local output
        output="$(smartctl -A -H -n standby "$d" 2>/dev/null)" || true
        if grep -q 'STANDBY' <<<"$output"; then
            report ok "$d in standby (not woken up)"
            continue
        fi

        # The attributes that predict failures, not the overall verdict
        local realloc pending unreadable temp
        realloc="$(awk '$1==5   {print $10+0}' <<<"$output")"
        pending="$(awk '$1==197 {print $10+0}' <<<"$output")"
        unreadable="$(awk '$1==198 {print $10+0}' <<<"$output")"
        temp="$(awk '$1==194 {print $10+0}' <<<"$output")"

        if (( ${unreadable:-0} > 0 || ${pending:-0} > 0 )); then
            report critical "$d: $pending pending, $unreadable unreadable: REPLACE IT"
        elif (( ${realloc:-0} > 0 )); then
            report warn "$d: $realloc reallocated sectors: watch it"
        else
            report ok "$d with no bad sectors"
        fi

        if (( ${temp:-0} >= 50 )); then
            report critical "$d at ${temp}C"
        elif (( ${temp:-0} >= HOME_TEMP_THRESHOLD )); then
            report warn "$d at ${temp}C (ideal < 45)"
        fi
    done
}

check_services() {
    local s
    for s in jellyfin smbd smartd; do
        systemctl is-active --quiet "$s" \
            && report ok "$s active" \
            || report critical "$s is NOT active"
    done

    # That it responds, not just that the process exists
    if curl -sf -m 5 -o /dev/null http://127.0.0.1:8096/health; then
        report ok "jellyfin responds"
    else
        report critical "jellyfin does not respond on 8096"
    fi
}

check_backups() {
    local marker=/var/lib/home-backup/last-successful
    if [[ ! -f "$marker" ]]; then
        report critical "there is no record of any successful backup"
        return 0
    fi
    local hours
    hours=$(( ( $(date +%s) - $(stat -c %Y "$marker") ) / 3600 ))
    if (( hours > HOME_BACKUP_HOURS * 2 )); then
        report critical "last successful backup ${hours} h ago"
    elif (( hours > HOME_BACKUP_HOURS )); then
        report warn "last successful backup ${hours} h ago"
    else
        report ok "last successful backup ${hours} h ago"
    fi
}

check_acceleration() {
    [[ -e /dev/dri/renderD128 ]] || { report warn "no GPU reachable"; return 0; }
    if vainfo --display drm --device /dev/dri/renderD128 2>/dev/null | \
            grep -q VAEntrypointEncSlice; then
        report ok "hardware acceleration available"
    else
        report warn "the GPU does not expose hardware encoding"
    fi
}

main() {
    require_command btrfs
    require_command curl

    check_mount
    check_space
    check_btrfs_errors
    check_scrub
    check_disks
    check_services
    check_backups
    check_acceleration

    case "$overall_status" in
        0) log "home media server correct" ;;
        1) error "check completed with WARNINGS" ;;
        2) error "check completed in a CRITICAL state" ;;
    esac
    return "$overall_status"
}

main "$@"
$ shellcheck ~/scripts/home_check.sh && echo "no warnings"
no warnings

$ ~/scripts/home_check.sh; echo "status: $?"
[2026-08-18 08:00:03] OK       library mounted
[2026-08-18 08:00:03] OK       RAID 1 with 2 devices
[2026-08-18 08:00:03] OK       space at 28% (6.3 TiB free)
[2026-08-18 08:00:04] OK       no I/O or corruption errors
[2026-08-18 08:00:04] OK       last scrub 12 days ago
[2026-08-18 08:00:04] OK       /dev/sda in standby (not woken up)
[2026-08-18 08:00:04] OK       /dev/sdb in standby (not woken up)
[2026-08-18 08:00:05] OK       jellyfin active
[2026-08-18 08:00:05] OK       smbd active
[2026-08-18 08:00:05] OK       smartd active
[2026-08-18 08:00:05] OK       jellyfin responds
[2026-08-18 08:00:05] OK       last successful backup 6 h ago
[2026-08-18 08:00:06] OK       hardware acceleration available
[2026-08-18 08:00:06] home media server correct
status: 0

Five design decisions that deserve justification:

  1. -n standby in smartctl. Without that option, the daily check wakes the disks every morning, cancelling out the energy saving from section 13 and adding a daily spin-up cycle to each disk. A monitoring script that degrades what it monitors is a bad script.
  2. Checking the number of devices, not just the mount. With nofail, a disconnected disk does not stop the machine booting and the system works normally, degraded and without redundancy. It is a silent failure: without this check, you find out on the day the second one fails.
  3. Not using df with Btrfs in RAID. It gives misleading figures because it does not understand that every block is written twice. btrfs filesystem usage -b is used instead.
  4. The SMART attributes, not the verdict. smartctl -H says PASSED on disks that are going to fail next week. Attributes 5, 197 and 198 are the ones that predict.
  5. A marker file for the backups, written by home_backup.sh only after verifying. Checking that the timer ran is not enough: it could have run and failed. What matters is that there was a successful backup.
# /etc/systemd/system/home-check.timer
[Unit]
Description=Daily check of the home media server

[Timer]
OnCalendar=*-*-* 08:00:00
Persistent=true

[Install]
WantedBy=timers.target
# /etc/systemd/system/home-check.service
[Unit]
Description=Health check of the home media server
# Notify ONLY on failure: silence when all is well
OnFailure=notify-home@%n.service

[Service]
Type=oneshot
User=operator
ExecStart=/home/operator/scripts/home_check.sh

With Type=oneshot and no redirections, the output goes to the journal and only a non-zero code triggers OnFailure. It is the course's "silence when all is well" convention, and at home it matters even more: a daily email saying "everything fine" stops being read within two weeks, and with it the ones that do matter stop being read too. It is the alert fatigue of 08-06, in a domestic version.

Conclusion

You have built a complete server from scratch, and along the way you have found the answer to the question this lesson opened with: there was nothing specific to "enterprise servers". You mounted by UUID with nofail because in 05-04 you learned that an absent disk can leave you without remote access. You put the repository key in keyrings because in 05-03 you learned that apt-key is obsolete. You hardened the systemd unit until the exposure came down to 2.4, with a ReadOnlyPaths over the library that stops a bug in the application from deleting twenty years of photos. You validated the Samba configuration with testparm -s before applying it, which is the same nginx -t from 08-01 under another name. And you left it all in Ansible, rebuildable like srv-tramontana.

You have taken justified decisions instead of copying recipes. Btrfs in RAID 1 instead of mdadm, because checksumming detects and repairs the silent corruption that a classic RAID propagates into the backups — and you saw it working in a scrub that corrected two blocks. Jellyfin instead of Plex, because it does not depend on anybody's cloud and its hardware acceleration is not a paid feature. The VPN instead of exposing the service to the internet, because a Jellyfin vulnerability must not be a vulnerability of your house. And a tiered backup strategy that protects what is irreplaceable for €24 a year instead of trying to protect everything for €360 and ending up protecting nothing.

You have also learned two things that work does not teach you. That electricity consumption is an architectural decision: €110 a year of difference between a mini PC and a repurposed PC, which over three years pays for the whole machine. And that RAID covers exactly one of the seven threats in the table, while Btrfs snapshots, verified backups and the original discs on the shelf cover the other six. With the sentence to take away: RAID protects against the failure of a disk and against nothing else.

In 08-04 you build the WireGuard VPN server that this lesson has recommended twice. You are going to understand why WireGuard is a network interface and not a daemon, why AllowedIPs is routing and access control at the same time — the most misunderstood concept in the whole tool — and how to decide between split tunnelling and full tunnelling with judgement instead of out of habit. By the end you will have access to Tramontana's internal network from anywhere without exposing a single TCP port, you will be able to close PostgreSQL and the statistics panel to the outside because they will no longer need to be open, and along the way your library at home will be reachable from a hotel with a single UDP port open on the router. And you will see why a VPN, for all its virtues, does not turn an internal network into a secure network.

Linux Course: From Beginner to System Administrator

Module 1: Introduction to Linux

Module 2: Basic Linux Commands

Module 3: Advanced Command-Line Skills

Module 4: Shell Scripting

Module 5: System Administration

Module 6: Networking and Security

Module 7: Advanced Topics

Module 8: Practical Projects

© Copyright 2026. All rights reserved