Zarif's Homelab Playbook (Debian + Docker) — Notebook Edition

Last Updated: 2026-04-21 02:50 UTC

How to use this notebook


Table of Contents

Zarif’s Homelab Playbook (Debian + Docker)

A practical, copy/paste-friendly record of what I set up and learned so far. Includes: install notes, display fixes, networking, users/sudo, firewall, Docker/Compose, Jellyfin, Navidrome, file transfer, media folder conventions, subtitle bulk renaming, and “next steps” for Nextcloud + remote access.


0) Build

Hardware: Dell OptiPlex 9020 SFF (i7, 32GB RAM, SSD)
OS: Debian 12 “Bookworm”
Service manager: systemd
App platform: Docker + Docker Compose
Running services: Jellyfin (video), Navidrome (music)

Core idea: the OS stays mostly stable; each “app” is a container with its own config stored on disk via volumes.


1) Installation notes (Debian)

Secure Boot / UEFI

Make sure you have Internet Connection during install

While it's possible to connect to the internet later on, configuring wifi after installation is a nightmare. See 4) for notes on plug-and-play wifi adapters. WHile I didn't use netinst, I recommend getting the netinst iso of bookworm when installing with an internet connection.

Headless install preference

If you want a headless server, during install don’t select a desktop environment.
(If you accidentally got a GUI, you can remove it later, but it’s easier to avoid up front.)

Domain name prompt during install

Debian asks for a domain name; for a home LAN it’s fine to use:

This does not “expose” you to the internet—it's just local naming.


2) TV / overscan / console cut-off fixes(optional)

At the time, I did not own a computer monitor, so I had to use my TV, my TV had some overscan issues that I couldn't fix in it's own settings. You likely won't run into this issue.

A) Simple: set a smaller console resolution

Pick a resolution that fits the TV without overscan cropping.

B) GRUB display fix (useful to remember)

Edit GRUB defaults:

sudo nano /etc/default/grub

Common options:

GRUB_GFXMODE=1024x768
GRUB_GFXPAYLOAD_LINUX=keep

If you need very safe:

GRUB_GFXMODE=640x480

Apply changes:

sudo update-grub
sudo reboot

Note: TVs vary a lot. Smaller modes are boring but reliable.


3) Users, root, sudo

Considerations

Add your user to sudo group (Debian)

As root:

usermod -aG sudo username

Then log out and back in for group membership to apply.

Verify your user + groups

id username
groups username
getent group sudo

Install sudo (if missing)

su -
apt update
apt install sudo

If apt tries to use a CD/DVD only, see “APT sources” below.


4) Networking basics (Wi‑Fi / “why VPN breaks LAN access”)

Wi‑Fi issues

I bought an older machine without a built in wifi card, the usb wifi it came with had weird drivers that weren't natively supported with linux. I eventually got a plug‑and‑play adapter that created an interface like:

Confirm network interface + IP

ip a

Quick connectivity tests

ping -c 3 1.1.1.1
ping -c 3 deb.debian.org

If IP works but DNS fails, your DNS config is the issue.

VPN Note

If you want to access your device via ssh(see:6) you might run into issues if that device is connected to a VPN. When you connect to a typical commercial VPN, your device’s routes change and local traffic (your 192.168.x.x LAN) may no longer be reachable. Many VPN apps have a setting like:

Power Saving Mode Note

If your server for some reason is not ethernet connected(in my case it just wasn't possible), you might run into a problem with being able to access your server via LAN. I had a scenario where my server was internet connected and the services were accessible via https, but not available through LAN. It became a recurring issue, until I ran this command:

sudo iw dev [wifiinterface] set power_save off

5) APT sources (when apt install tries to use CD-ROM)

Sometimes Debian leaves a CD-ROM entry enabled in /etc/apt/sources.list.

Note: this likely won't be an issue with the netinst ISO.

Check:

cat /etc/apt/sources.list
ls /etc/apt/sources.list.d/

If you see lines like:

deb cdrom:[Debian GNU/Linux ...]

Comment them out by adding # at the start, then ensure you have real network repos, e.g.:

deb http://deb.debian.org/debian bookworm main contrib non-free-firmware
deb http://deb.debian.org/debian-security bookworm-security main contrib non-free-firmware
deb http://deb.debian.org/debian bookworm-updates main contrib non-free-firmware

Then:

sudo apt update

6) SSH: enable, connect, and harden

Install + start SSH server

sudo apt update
sudo apt install openssh-server
sudo systemctl enable --now ssh
sudo systemctl status ssh --no-pager

Find server IP (so you can SSH in)

ip a

SSH from another machine

ssh username@SERVER_IP

Basic hardening (good early defaults)

Edit SSH config:

sudo nano /etc/ssh/sshd_config

Recommended settings (start here):

PermitRootLogin no
PasswordAuthentication yes
``

**Later**, when you set up SSH keys, switch to:

```text
PasswordAuthentication no

Restart SSH after changes:

sudo systemctl restart ssh

If you’re remote when editing SSH settings, keep a second session open so you don’t lock yourself out.


7) Firewall (UFW) – “allow SSH, deny the rest by default”

If you forward ports from your router to your server and want remote SSH access, you should configure a firewall.

Install:

sudo apt install ufw

Good baseline:

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from YOUR_IP_ADDRESS to any port 22 proto tcp
sudo ufw enable
sudo ufw status verbose

This configuration only allows SSH connections from a trusted IP address.

There are botnets that constantly scan the internet for exposed SSH services and attempt password logins.

If your IP address changes frequently, you can instead allow SSH but rate-limit connection attempts:

sudo ufw limit 22/tcp

This allows SSH from anywhere but slows brute-force attempts.

To inspect firewall logs:

sudo grep UFW /var/log/syslog

or

sudo tail -f /var/log/ufw.log

8) Docker: what it is (and why you used it)

Docker runs apps in isolated environments called containers.

There are alternatives to docker, to run apps in containers if you prefer something else.

Install Docker + Compose

On Debian 12:

sudo apt update
sudo apt install docker.io docker-compose-plugin
sudo systemctl enable --now docker

Optional: allow your user to run docker without sudo (tradeoff: convenience vs security)

sudo usermod -aG docker username
# log out/in

Then test:

docker run --rm hello-world

9) Folder architecture (host filesystem)

A clean pattern that scales:

/srv/
  stack/                # compose files + .env
  media/
    movies/
    shows/
    music/
  jellyfin/
    config/
    cache/
  navidrome/
    data/

Create folders:

sudo mkdir -p /srv/{stack,media/{movies,shows,music},jellyfin/{config,cache},navidrome/data}
sudo chown -R username:username /srv/stack /srv/media /srv/jellyfin /srv/navidrome

For media folders, ownership can be more flexible, but keeping it consistent avoids surprises.


10) Jellyfin (Docker)

Jellyfin is a service that allows you to access and stream media on a server. You can organize files into it's media folder and it will scrape the data of any 'dvds' you may have ripped and placed into the folders. The interface is similar to a lot of video streaming apps and you populate it with your personal collection. It has plugins, community made themes, and other customizable options. It also can stream audio, among other things, but we will be using navidrome for audio.

“One-shot” docker run (what I learned first, the method improves in 12)

docker run -d   --name jellyfin   --restart=unless-stopped   -p 8096:8096   -v /srv/jellyfin/config:/config   -v /srv/jellyfin/cache:/cache   -v /srv/media:/media   jellyfin/jellyfin

What each part means:

Note on backslashes mattered when typing

The \ at line end escapes the newline. If you missed a backslash while typing, Docker saw a “broken” command and threw errors like “invalid reference format”. Pasting preserved the exact syntax.


11) Navidrome (Docker)

Navidrome is a lightweight music server. There are numerous navidrome clients that you can use to stream music on your phone from with navidrome. You may also directly access the container via browser.

Recommended: run via Docker Compose (see below)

It uses:


12) Docker Compose (better method)

Compose lets you define a whole stack in YAML:

Your working file location:

/srv/stack/docker-compose.yml

Bring up services:

cd /srv/stack
docker compose up -d

See status:

docker compose ps

View logs:

docker compose logs -f jellyfin

Stop services:

docker compose down

13) Current Docker Compose (Jellyfin + Navidrome)

Create/edit:

nano /srv/stack/docker-compose.yml

Example:

services:
  jellyfin:
    image: jellyfin/jellyfin:latest
    container_name: jellyfin
    restart: unless-stopped
    ports:
      - "8096:8096"
    volumes:
      - /srv/jellyfin/config:/config
      - /srv/jellyfin/cache:/cache
      - /srv/media:/media

  navidrome:
    image: deluan/navidrome:latest
    container_name: navidrome
    restart: unless-stopped
    ports:
      - "4533:4533"
    volumes:
      - /srv/navidrome/data:/data
      - /srv/media/music:/music:ro
    environment:
      - ND_SCANINTERVAL=1h

Then:

cd /srv/stack
docker compose up -d

Access:


14) File transfer (SFTP / Cyberduck)

Use SFTP (not FTP)

To transfer files from my laptop to the server I used an SFTP client(Cyberduck), there are other available ones, use the one that you prefer. In Cyberduck:

“No sudo in Cyberduck” workaround

I ran into the issue that I couldn't transfer files to all these folders made in the root directories (/srv/media/) as these were not owned by my user, and I can't sudo in Cyberduck. Best practice is NOT logging in as root for file transfer.

Instead:

Example permission model:

sudo chown -R username:username /srv/media
sudo chmod -R 755 /srv/media

If you want tighter permissions later, you can look into group-based access.


15) Jellyfin playback troubleshooting: “MP4 opens then closes” (Optional)

You hit:

Key takeaway: “MP4” doesn’t guarantee the container metadata is friendly for streaming/transcoding.

“Remux” fix (no re-encode, just rewrap)

If you ever need it again, use host ffmpeg:

sudo apt install ffmpeg
cd "/srv/media/shows/Some Show/Season 1"
ffmpeg -i "episode.mp4" -map 0:v:0 -map 0:a:0 -c copy "episode-fixed.mkv"

(We map only video+audio to avoid subtitle/art attachment stream issues when remuxing.)

Full Disclosure: While following these steps may have solved some playback issues I was having, there's a possibility that this didn't do anything at all. But in case you have some issues with certain filetypes see if this helps.


16) Subtitle matching + bulk renaming (Optional)

Jellyfin matches external subtitles when the base name matches:

VideoName.mp4
VideoName.en.ass
VideoName.zxx.ass

Problem I hit

Subtitle names didn’t match video basenames, and Windows bulk rename was untrustworthy.

Working strategy

Rename subtitle files to match the corresponding video file base name, while preserving each subtitle “variant” so they don’t overwrite each other.

Template idea:

Safe rename script (rename subs to match videos)

Run inside the season folder after videos + subs are both present:

cd "/srv/media/shows/ShowName/Season 1"

# DRY RUN (prints changes only)
for sub in *.ass; do
  ep=$(echo "$sub" | sed -n 's/.* - \([0-9][0-9]\).*/\1/p')
  mapfile -t vids < <(compgen -G "* - $ep"*".mp4")

  if [ "${#vids[@]}" -ne 1 ]; then
    echo "SKIP ep=$ep vids=${#vids[@]} :: $sub"
    continue
  fi

  vid="${vids[0]}"
  base="${vid%.mp4}"

  # Variant: everything after the hash bracket in the subtitle filename
  variant=$(echo "$sub" | sed -n 's/.*\]\.\(.*\)\.ass$/\1/p')

  new="${base}.${variant}.ass"
  echo "$sub -> $new"
done

When it looks right, replace the final echo with:

mv -- "$sub" "$new"

The -- prevents weird filenames from being treated as options by mv.


17) Operational habits

A) Keep a “stack handbook” folder

Keep copies of important configuration files and notes so you can quickly rebuild or troubleshoot your stack.

mkdir -p ~/homelab-handbook
cp /srv/stack/docker-compose.yml ~/homelab-handbook/

You can also store notes such as:

B) Learn the “lifecycle” commands

These are the core commands for managing Docker Compose stacks.

Start containers:

docker compose up -d

Stop containers:

docker compose down

View logs:

docker compose logs -f SERVICE

Update containers:

docker compose pull && docker compose up -d

C) Update Debian regularly

Keep the base system updated for security patches and package improvements.

sudo apt update
sudo apt upgrade

D) Prefer H.264 video encoding for maximum compatibility

When ripping or encoding media for Jellyfin, H.264 (AVC) is usually the safest choice.

Most devices support H.264 decoding natively, which allows direct play without server transcoding.

Advantages of H.264:

H.265 (HEVC) provides better compression and smaller file sizes, but:

If your playback devices support HEVC, it can be a good option to save storage space while maintaining high quality.

Summary:

E) Monitor disk usage

sudo df -h
sudo du -sh /srv/media/*

18) Security practices

Now (simple + effective):

Planned:


19) Nextcloud plan

Nextcloud is more “app stack” than Jellyfin:

We will implement it via Docker Compose with:


Appendix: “quick reference” commands

Docker

docker ps
docker logs jellyfin --tail 100
docker restart jellyfin

Networking

ip a
ping -c 3 1.1.1.1
ping -c 3 deb.debian.org

Users/groups

id username
groups group
sudo -v

UFW

sudo ufw status verbose
sudo ufw allow 8096/tcp
sudo ufw limit 22/tcp

End of current playbook.

Appendix — Copy/Paste Task Cards

Short, practical blocks for common tasks.

Bring the stack up/down

cd /srv/stack
docker compose up -d
docker compose ps
docker compose down

Update containers (typical safe routine)

cd /srv/stack
docker compose pull
docker compose up -d
docker image prune -f

Subtitle rename DRY RUN (run inside a Season folder)

Example path shown; adjust to your show/season folder.

cd "/srv/media/shows/Show/Season 1"
for sub in *.ass; do
  ep=$(echo "$sub" | sed -n 's/.* - \([0-9][0-9]\).*/\1/p')
  mapfile -t vids < <(compgen -G "* - $ep"*".mp4")

  if [ "${#vids[@]}" -ne 1 ]; then
    echo "SKIP ep=$ep vids=${#vids[@]} :: $sub"
    continue
  fi

  vid="${vids[0]}"
  base="${vid%.mp4}"
  variant=$(echo "$sub" | sed -n 's/.*\]\.\(.*\)\.ass$/\1/p')

  new="${base}.${variant}.ass"
  echo "$sub -> $new"
done

Subtitle rename APPLY (CAREFUL) — replace echo with mv

cd "/srv/media/shows/Show/Season 1"
for sub in *.ass; do
  ep=$(echo "$sub" | sed -n 's/.* - \([0-9][0-9]\).*/\1/p')
  mapfile -t vids < <(compgen -G "* - $ep"*".mp4")

  if [ "${#vids[@]}" -ne 1 ]; then
    echo "SKIP ep=$ep vids=${#vids[@]} :: $sub"
    continue
  fi

  vid="${vids[0]}"
  base="${vid%.mp4}"
  variant=$(echo "$sub" | sed -n 's/.*\]\.\(.*\)\.ass$/\1/p')

  new="${base}.${variant}.ass"
  mv -- "$sub" "$new"
done

Homelab Playbook — Nextcloud Expansion & Concepts

Overview

This section documents the Nextcloud deployment, architecture, background jobs, and supporting services (Redis + PostgreSQL).


Architecture Overview

Nextcloud is running as a Docker stack composed of:


Docker Compose Stack

services:

  db:
    image: postgres:15
    restart: unless-stopped
    env_file:
      - ./nextcloud.env
    volumes:
      - /srv/nextcloud/db:/var/lib/postgresql/data

  redis:
    image: redis:7
    restart: unless-stopped
    volumes:
      - /srv/nextcloud/redis:/data

  app:
    image: nextcloud:latest
    restart: unless-stopped
    ports:
      - "8080:80"
    env_file:
      - ./nextcloud.env
    depends_on:
      - db
      - redis
    volumes:
      - /srv/nextcloud/app:/var/www/html
      - /srv/nextcloud/data:/var/www/html/data

Environment Variables

POSTGRES_DB=nextcloud
POSTGRES_USER=nextcloud
POSTGRES_PASSWORD=YOUR_PASSWORD

NEXTCLOUD_ADMIN_USER=admin
NEXTCLOUD_ADMIN_PASSWORD=YOUR_PASSWORD

REDIS_HOST=redis

Folder Structure

/srv
 ├── stack
 │    ├── nextcloud-compose.yml
 │    ├── nextcloud.env
 │
 └── nextcloud
      ├── app
      ├── data
      ├── db
      └── redis

Background Jobs (Cron)

Nextcloud background jobs are configured to run via cron for reliability and performance.

Cron Entry

*/5 * * * * docker exec -u www-data nextcloud-app-1 php -f /var/www/html/cron.php > /dev/null 2>&1

What This Does

Runs every 5 minutes to:


Checking System Status

Containers

docker ps

Logs

docker compose -f nextcloud-compose.yml logs --tail=50

Nextcloud Status

docker exec -u www-data nextcloud-app-1 php occ status

Maintenance Commands

Add Missing Database Indices

docker exec -u www-data nextcloud-app-1 php occ db:add-missing-indices

Run Repair Tasks

docker exec -u www-data nextcloud-app-1 php occ maintenance:repair --include-expensive

Redis Explained

Redis is an in-memory data store used for:

Benefits:


PostgreSQL Explained

PostgreSQL is the primary database storing:

Actual files are stored on disk in /srv/nextcloud/data.


Why Separate Services

This architecture provides:


Current Expected Warnings

Normal for local deployment:


Future Improvements


Mental Model

Browser
   ↓
Nextcloud container
   ↓
Redis cache
   ↓
PostgreSQL database
   ↓
Disk storage

What Been Built

We now have a production-style self-hosted cloud stack with:


Homelab Infrastructure Playbook -- Phase 2

Full Transition from Internal-Only Services to Secure External Access via Cloudflare Tunnel

Last Updated: 2026-03-02 05:56 UTC


0. Starting Environment

This phase begins with an already functioning internal homelab:

Host

Services Running Internally


  Service     Port   Internal URL
  ----------- ------ ---------------------------
  Jellyfin    8096   http://SERVER_IP:8096
  Navidrome   4533   http://SERVER_IP:4533
  Nextcloud   8080   http://SERVER_IP:8080

Compose Layout

All compose files stored in:

/srv/stack

Service data stored in:


    /srv/jellyfin
    /srv/navidrome
    /srv/nextcloud

My constraints: - No router access - No port forwarding - WiFi Only

Goal: Expose services via domain securely without opening inbound ports.


1. Domain & Cloudflare Preparation

1.1 Cloudflare Account Creation

nslookup -type=ns yourdomain.com

Once nameservers resolved to Cloudflare → domain active.


1.2 Cloudflare DNS Configuration

Created proxied CNAME records:


  Subdomain   Target
  ----------- ----------------------------
  media       TUNNEL-ID.cfargotunnel.com
  music       TUNNEL-ID.cfargotunnel.com
  cloud       TUNNEL-ID.cfargotunnel.com

Important: - Orange cloud (Proxied) must be enabled. - Proxy ensures TLS termination and hides origin.


2. Cloudflare Zero Trust Setup

2.1 Zero Trust Initialization

Important realization: Locally-created tunnels appear as "Locally Managed" and cannot be edited in dashboard unless migrated.

We chose local management for full Docker control.


3. Cloudflare Tunnel Creation (Docker-Based)

3.1 Directory Preparation

Created tunnel directory:

sudo mkdir -p /srv/cloudflared

Initial permissions debugging required later adjustments.


3.2 Tunnel Login

docker run -it --user 0:0   -v /srv/cloudflared:/etc/cloudflared   cloudflare/cloudflared:latest tunnel login

Observed: - Cert saved inside container at /root/.cloudflared/cert.pem - Required manual extraction via:

docker cp cloudflared-login:/root/.cloudflared/cert.pem /srv/cloudflared/cert.pem

Key lesson: --rm removes container before file retrieval if not careful.


3.3 Create Named Tunnel

docker run --rm -it --user 0:0   -v /srv/cloudflared:/etc/cloudflared   cloudflare/cloudflared:latest tunnel create homelab

Result: - JSON credentials file generated - Tunnel ID issued - Credentials stored in /srv/cloudflared/

Security note: Keep JSON credentials secret.


4. Cloudflare Tunnel Configuration

Created:

/srv/cloudflared/config.yml

Contents:

tunnel: <TUNNEL-ID>
credentials-file: /etc/cloudflared/<TUNNEL-ID>.json

ingress:
  - hostname: media.domain.com
    service: http://192.168.1.204:8096

  - hostname: music.domain.com
    service: http://192.168.1.204:4533

  - hostname: cloud.domain.com
    service: http://192.168.1.204:8080

  - service: http_status:404

Important rule: Fallback rule must be last.


5. Docker Compose Integration

File:

/srv/stack/cloudflared-compose.yml

Final working configuration:

services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    container_name: cloudflared
    restart: unless-stopped
    command: tunnel run homelab
    volumes:
      - /srv/cloudflared:/etc/cloudflared

Started with:

docker compose -p cloudflared -f /srv/stack/cloudflared-compose.yml up -d

6. Debugging & Lessons Learned

6.1 Permission Errors

Error observed:

open /etc/cloudflared/config.yml: permission denied

Cause: Directory permissions blocked traversal.

Fix:

sudo chmod 755 /srv/cloudflared
sudo chmod 644 /srv/cloudflared/*

Lesson: Docker containers need read access to mounted files.


6.2 Restart vs Recreate

Observed: docker restart does NOT reload compose changes.

Correct procedure:

docker compose down
docker compose up -d

6.3 Healthy Tunnel Logs

Healthy logs contain:

INF Registered tunnel connection

Multiple connections expected (connIndex 0-3).


7. Nextcloud Reverse Proxy Configuration

Issue: "Access through untrusted domain"

Resolved by editing:

config/config.php

Located via:

docker inspect nextcloud-app-1 --format '{range .Mounts}{println .Destination "->" .Source}{end}'

Updated configuration:

'trusted_domains' =>
array (
  0 => '192.168.1.204:8080',
  1 => 'cloud.domain.com',
),

'overwrite.cli.url' => 'https://cloud.domain.com',
'overwriteprotocol' => 'https',

No restart required.


8. Jellyfin Password Reset Mechanism

Password reset generates file:

/config/passwordreset

Host path example:

/srv/jellyfin/config/passwordreset*

Retrieve via:

sudo cat /srv/jellyfin/config/passwordreset*

File deletes automatically after successful reset.

Security consideration: Requires server access → implies broader compromise scenario.


9. Bandwidth Considerations

External access consumes upload bandwidth.

Measured upload:

~41 Mbps

Practical limit:

LAN traffic does not consume internet bandwidth.


10. Security Model Decisions

Chosen:

Rejected:


11. Operational Commands

Check tunnel:

docker logs cloudflared --tail 50

Restart:

docker restart cloudflared

Shutdown:

docker compose -p cloudflared -f /srv/stack/cloudflared-compose.yml down

12. Final System State

This concludes full Phase 2 externalization.