Home server Notes

2025-12-14 04:00

Home Server Specs:

CPU: AMD 5600GT

GPU: Intel arc a380

Ram: 32GB(16 x 2 ) DDR4 3200Mhz

OS drive: 500GB adata nvme SSD(shortname nvme) .

Additional storage: 1TB sata SSD(shortname SSD), 1TB 2.5 inch HDD(short name HDD) .

OS: debian.

Whole setup:

All services are run by docker(compose). Each compose.yaml will be at ~/docker/service.

And each service data(except for arr stack and Immich) will be stored in the same folder as compose.yaml i.e ~/docker/service.

Immich data(not configs , cache but photos and videos) will be stored at ~/Immich on nvme drive.

English and Telugu movies will be on  SSD and rest all , like hindi , tamil etc movies and webseries will be on HDD.

After installing plain debian without any DE.

Shortcuts:

client: personal laptop/desktop and its username

server :server username

server_ip: local ip of server

Additional notes: My internet is public dynamic IP but port 80 and 443 are blocked, so using port 8443, like https://immich.mydomain.com:8443

#add current user to sudo
su -
apt update
apt install sudo
usermod -aG sudo yourusername
#log out and log back in

#install openssh: 
sudo apt install openssh-server
sudo systemctl status ssh
sudo systemctl enable ssh

Mounting the drives and setup automount:

# Create mount points
sudo mkdir -p /home/server/SSD
sudo mkdir -p /home/server/HDD
sudo chown server:server /home/server/SSD
sudo chown server:server /home/server/HDD

lsblk -f

# Edit fstab
sudo nano /etc/fstab
# Example expected output:
# UUID=2d1bXXXXX-XXXX-XXXX-XXXX-XXXX8eb4  -> SSD
# UUID=ebd9XXXXX-XXXX-XXXX-XXXX-XXXa1fe407f  -> HDD

# Add these lines:
UUID=2d1bXXXXX-XXXX-XXXX-XXXX-XXXX8eb4 /home/server/SSD ext4 defaults,nofail 0 2
UUID=ebd9XXXXX-XXXX-XXXX-XXXX-XXXa1fe407f /home/server/HDD ext4 defaults,nofail 0 2

# Test mount
sudo mount -a

#Install arc drivers drivers(look online for updated info)
sudo apt update
sudo apt install \
  mesa \
  mesa-va-drivers \
  mesa-vulkan-drivers \
  intel-media-va-driver \
  intel-opencl-icd \
  ocl-icd-libopencl1 \
  libva-utils

SSH hardening :

#Generate SSH key and copy to server:
#to be generated on personal pc/laptop
ssh-keygen -t ed25519 -C "user@client"
ssh-copy-id server@server_ip

sudo nano /etc/ssh/sshd_config
#modify these lines: 
# (Choose a high, random port)
Port 42456
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
PermitEmptyPasswords no
MaxAuthTries 3
MaxSessions 5
LoginGraceTime 30
X11Forwarding no
StrictModes yes

#later to to test and restart ssh:
sudo sshd -t
sudo systemctl restart sshd


# Install UFW
sudo apt install ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 42456/tcp
sudo ufw allow 8443/tcp  #alternativef port to 443
sudo ufw allow from 192.168.0.0/24
sudo ufw --force enable
sudo ufw status verbose
sudo systemctl enable ufw
sudo systemctl start ufw



# Install Fail2Ban
sudo apt install fail2ban
sudo nano /etc/fail2ban/jail.local


[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 5
ignoreip = 127.0.0.1/8 192.168.0.0/24

[sshd]
enabled = true
port = 42456
backend = systemd
maxretry = 3

# Add these AFTER nginx setup
[nginx-http-auth]
enabled = true
mode = normal

[nginx-botsearch]
enabled = true
mode = normal


sudo systemctl enable fail2ban
sudo systemctl start fail2ban

sudo systemctl status fail2ban
sudo fail2ban-client status

Install docker and docker compose:

# Add Docker's official GPG key:
sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

# Add the repository to Apt sources:
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: stable
Signed-By: /etc/apt/keyrings/docker.asc
EOF

sudo apt update

sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin


sudo systemctl start docker
sudo systemctl enable docker

sudo usermod -aG docker $USER

Setup services.

List of services: Immich, Arr stack(jellyfin, qbittorrent, prowlarr, radarr, sonarr, jellyseer), fmd-server, navidrome, openspeedtest, paperless, filebrowser, diun, ntft, baikal.

Additional info: All services to be behing cloudflare tunnel except immich, jellyfin and openspeedtest.

For each service, make new folder, like ~/docker/baikal for baikal etc. Paste the following contents in each(with appropriate edits) and run docker compose up -d when inside the service folder

If any services , shows error, run docker compose logs -f to see the logs and diagnose(use gemini/claude help)

1. Baikal conf.

services:
  baikal:
    image: ckulka/baikal:nginx
    container_name: baikal
    restart: always
    ports:
      - "8844:80"
    volumes:
      - ./config:/var/www/baikal/config
      - ./data:/var/www/baikal/Specific

2. findmy: foss equivalent to google/apple findmy

#in compose.yaml:
services:
  fmd:
    image: registry.gitlab.com/fmd-foss/fmd-server
    container_name: findmy-server
    ports:
      - "8082:8080"  # Changed from 127.0.0.1:8082:8080 to 8082:8080
    volumes:
      - "./fmddata/db/:/var/lib/fmd-server/db/"
      - "./config.yml:/etc/fmd-server/config.yml:ro"
    restart: unless-stopped
    read_only: true
    cap_drop: [ALL]
    security_opt: [no-new-privileges]

#in config.yml
# FMD Server Configuration
PortInsecure: 8080  # This is the port inside the container
DatabaseDir: "/var/lib/fmd-server/db/"

# Optional: Set registration to require approval (recommended for security)
# RequireAccountApproval: true

# Optional: Set maximum number of devices per account
# MaxDevicesPerAccount: 5

# Require this token to register new accounts
#change this
RegistrationToken: "supersecurepassword"

3. filebrowser

services:
  filebrowser:
    image: filebrowser/filebrowser:latest
    container_name: filebrowser
    restart: unless-stopped
    ports:
      - "8084:80"
    volumes:
      - /home/server:/srv  # Your entire home directory
      - ./filebrowser.db:/database.db  # Database file
      - ./settings.json:/config/settings.json  # Config
    environment:
      - PUID=1000
      - PGID=1000

3. Navidrome:

services:
  navidrome:
    image: deluan/navidrome:latest
    user: 1000:1000 # Ensure this matches the owner of the volumes
    ports:
      - "4533:4533"
    restart: unless-stopped
    environment:
      ND_SCANSCHEDULE: 1h
      ND_LOGLEVEL: info  
      ND_SESSIONTIMEOUT: 24h
      ND_BASEURL: ""
    volumes:
      - "./data:/data"  # Stores config data in the same folder as compose.yaml
      - "/home/server/Music:/music:ro"  # Read-only access to your music folder. Music directory at ~/Music

4. openspeedtest:

services:
    speedtest:
        restart: unless-stopped
        container_name: openspeedtest
        ports:
            - '3000:3000'
            - '3001:3001'
        image: openspeedtest/latest

5. Immich:

#Best to follow from official Immich documentation. But keeping as notes for ease of reference.
#Also hw transcoding and ML tasks depend on hardware. So, tweak it.
#compose.yaml: 
name: immich

services:
  immich-server:
    container_name: immich_server
    image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
    extends:
      file: hwaccel.transcoding.yml
      service: vaapi
    volumes:
      - ${UPLOAD_LOCATION}:/data
      - /etc/localtime:/etc/localtime:ro
    env_file:
      - .env
    ports:
      - "2283:2283"
    depends_on:
      - redis
      - database
    restart: always
    healthcheck:
      disable: false

  immich-machine-learning:
    container_name: immich_machine_learning
    image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}-openvino
    extends:
      file: hwaccel.ml.yml
      service: openvino
    volumes:
      - model-cache:/cache
    env_file:
      - .env
    restart: always
    healthcheck:
      disable: false

  redis:
    container_name: immich_redis
    image: docker.io/valkey/valkey:8-bookworm@sha256:a137a2b60aca1a75130022d6bb96af423fefae4eb55faf395732db3544803280
    healthcheck:
      test: redis-cli ping || exit 1
    restart: always

  database:
    container_name: immich_postgres
    image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:32324a2f41df5de9efe1af166b7008c3f55646f8d0e00d9550c16c9822366b4a
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_USER: ${DB_USERNAME}
      POSTGRES_DB: ${DB_DATABASE_NAME}
      POSTGRES_INITDB_ARGS: '--data-checksums'
      # Uncomment if DB is stored on HDD
      # DB_STORAGE_TYPE: 'HDD'
    volumes:
      - ${DB_DATA_LOCATION}:/var/lib/postgresql/data
    shm_size: 128mb
    restart: always

volumes:
  model-cache:

#.env :
# You can find documentation for all the supported env variables at https://immich.app/docs/install/environment-variables

# The location where your uploaded files are stored
UPLOAD_LOCATION=/home/server/Immich/library
# The location where your database files are stored
DB_DATA_LOCATION=/home/server/Immich/postgres

# To set a timezone, uncomment the next line and change Etc/UTC to a TZ identifier from this list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
TZ=Asia/Kolkata

# The Immich version to use. You can pin this to a specific version like "v1.71.0"
IMMICH_VERSION=release

# Connection secret for postgres. You should change it to a random password
# Please use only the characters `A-Za-z0-9`, without special characters or spaces
DB_PASSWORD=secure_password

# The values below this line do not need to be changed
###################################################################################
DB_USERNAME=postgres
DB_DATABASE_NAME=immich




#run these commands to find video and render GID. 
getent group video #example 984
getent group render . #example 988
ls -l /dev/dri #gives render device, example renderD128

#hwaccel.ml.yml:
services:
  openvino:
    devices:
      - /dev/dri/renderD128:/dev/dri/renderD128 #based on ls -l /dev/dri output
    group_add:
      - "984"  # Your 'video' GID
      - "988"  # Your 'render' GID

#hwaccel.transcoding.yml:
services:
  vaapi:
    devices:
      - /dev/dri/renderD128:/dev/dri/renderD128
    group_add:
      - "984"  # Your 'video' GID
      - "988"  # Your 'render' GID
      

#full setup not yet completed. Had to change settings in immich web settings too. best to look online or use claude/chatgpt. 

6. Arr setup: Also highly depend on system.

mkdir ~/docker/streaming
cd docker/streaming

name: "streaming"
services:
  jellyfin:
    image: jellyfin/jellyfin:latest
    container_name: jellyfin
    user: 1000:1000
    group_add:
      - "988"
    network_mode: host
    environment:
      - JELLYFIN_PublishedServerUrl=https://jellyfin.mydomain.com:8443  # mostly no need of this. 
      - TZ=Asia/Kolkata
    volumes:
      - ./jellyfin/config:/config
      - ./jellyfin/cache:/cache
      # Dual-Path Mapping for TRaSH Guides compliance
      - /home/server/SSD/data:/data/ssd
      - /home/server/HDD/data:/data/hdd
    devices:
      # INTEL ARC A380 (03:00.0)
      - /dev/dri/renderD128:/dev/dri/renderD128
      - /dev/dri/card0:/dev/dri/card0
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8096/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  jellyseerr:
    image: fallenbagel/jellyseerr:latest
    container_name: jellyseerr
    networks:
      - media-net
    environment:
      - LOG_LEVEL=info
      - TZ=Asia/Kolkata
      - PORT=5055
    ports:
      - 5055:5055
    volumes:
      - ./jellyseerr/config:/app/config
    dns:
      - 1.1.1.1
      - 8.8.8.8
    depends_on:
      jellyfin:
        condition: service_healthy
    restart: unless-stopped

  prowlarr:
    image: lscr.io/linuxserver/prowlarr:latest
    container_name: prowlarr
    networks:
      - media-net
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Asia/Kolkata
    volumes:
      - ./prowlarr/config:/config
    ports:
      - 9696:9696
    restart: unless-stopped

  qbittorrent:
    image: lscr.io/linuxserver/qbittorrent:latest
    container_name: qbittorrent
    networks:
      - media-net
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Asia/Kolkata
      - WEBUI_PORT=8080
    volumes:
      - ./qbittorrent/config:/config
      # Map SSD & HDD to allow atomic moves within each drive
      - /home/server/SSD/data:/data/ssd
      - /home/server/HDD/data:/data/hdd
    ports:
      - 8080:8080
      - 50575:50575
      - 50575:50575/udp
    restart: unless-stopped

  radarr:
    image: lscr.io/linuxserver/radarr:latest
    container_name: radarr
    networks:
      - media-net
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Asia/Kolkata
    volumes:
      - ./radarr/config:/config
      # Map SSD & HDD exactly like qBittorrent
      - /home/server/SSD/data:/data/ssd
      - /home/server/HDD/data:/data/hdd
    ports:
      - 7878:7878
    depends_on:
      - prowlarr
      - qbittorrent
    restart: unless-stopped

  sonarr:
    image: lscr.io/linuxserver/sonarr:latest
    container_name: sonarr
    networks:
      - media-net
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Asia/Kolkata
    volumes:
      - ./sonarr/config:/config
      # Map SSD & HDD exactly like qBittorrent
      - /home/server/SSD/data:/data/ssd
      - /home/server/HDD/data:/data/hdd
    ports:
      - 8989:8989
    depends_on:
      - prowlarr
      - qbittorrent
    restart: unless-stopped

networks:
  media-net:
    driver: bridge
    


#setup not yet complete. had to integrate each service to each other and follow trash guides. Better to use gemini

7. Ntfy (for push notifications :for jellyseer, findmy, ) :

services:
  ntfy:
    image: binwiederhier/ntfy
    container_name: ntfy
    command:
      - serve
    environment:
      - TZ=Asia/Kolkata  # Set to your timezone
    volumes:
      - "./ntfydata/cache:/var/cache/ntfy"
      - "./ntfydata/config:/etc/ntfy:ro"
    ports:
      - "8083:80"
    healthcheck:
      test: ["CMD-SHELL", "wget -q --tries=1 http://localhost:80/v1/health -O - | grep -Eo '\"healthy\"\\s*:\\s*true' || exit 1"]
      interval: 60s
      timeout: 10s
      retries: 3
      start_period: 40s
    restart: unless-stopped

8. diun

Can be used for many: jellyseer, findmy, foss apps like forkgram, nginx geoblocks etc.

name: diun

services:
  diun:
    image: crazymax/diun:latest
    container_name: diun
    command: serve
    volumes:
      - "./data:/data"
      - "/var/run/docker.sock:/var/run/docker.sock:ro"
    environment:
      - "TZ=Asia/Kolkata"
      - "LOG_LEVEL=info"
      - "LOG_JSON=false"
      - "DIUN_WATCH_WORKERS=20"
      - "DIUN_WATCH_SCHEDULE=0 */6 * * *"
      - "DIUN_WATCH_JITTER=30s"
      - "DIUN_PROVIDERS_DOCKER=true"
      - "DIUN_PROVIDERS_DOCKER_WATCHBYDEFAULT=true"
      # Ntfy notification settings
      - "DIUN_NOTIF_NTFY_ENDPOINT=http://server_ip:8083"
      - "DIUN_NOTIF_NTFY_TOPIC=docker-updates-YOUR-SECURE-TOPIC"
      - "DIUN_NOTIF_NTFY_PRIORITY=3"
      - "DIUN_NOTIF_NTFY_TIMEOUT=10s"
    labels:
      - "diun.enable=true"
    restart: unless-stopped

9. Home assistant:

services:
  homeassistant:
    container_name: homeassistant
    image: ghcr.io/home-assistant/home-assistant:stable
    restart: unless-stopped
    privileged: true
    environment:
      - TZ=Asia/Kolkata
    volumes:
      - ./config:/config
      - /run/dbus:/run/dbus:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "8123:8123"
    network_mode: host
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8123"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s
    depends_on:
      - whisper
      - piper

#below ones are optional. But nice to have. Not that good
  # Whisper for Speech-to-Text
  whisper:
    container_name: whisper
    image: rhasspy/wyoming-whisper
    restart: unless-stopped
    volumes:
      - ./whisper:/data
    environment:
      - TZ=Asia/Kolkata
    ports:
      - "10300:10300"
    command: --model tiny-int8 --language en

  # Piper for Text-to-Speech
  piper:
    container_name: piper
    image: rhasspy/wyoming-piper
    restart: unless-stopped
    volumes:
      - ./piper:/data
    environment:
      - TZ=Asia/Kolkata
    ports:
      - "10200:10200"
    command: --voice en_US-lessac-medium

  # # OpenWakeWord for Wake Word Detection (Optional)
  # openwakeword:
  #   container_name: openwakeword
  #   image: rhasspy/wyoming-openwakeword
  #   restart: unless-stopped
  #   volumes:
  #     - ./openwakeword:/data
  #   environment:
  #     - TZ=Asia/Kolkata
  #   ports:
  #     - "10400:10400"
  #   command: --preload-model ok_nabu

10. Forgejo(self hosted git)

networks:
  forgejo:
    external: false

services:
  server:
    image: codeberg.org/forgejo/forgejo
    container_name: forgejo
    environment:
      - USER_UID=1000
      - USER_GID=1000
    restart: always
    networks:
      - forgejo
    volumes:
      - ./forgejo:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - '3030:3000'
      - '2222:22'

11. Paperless-ngx:

#compose.yaml:
services:
  broker:
    image: docker.io/library/redis:8
    restart: unless-stopped
    volumes:
      - ./redisdata:/data

  db:
    image: docker.io/library/postgres:17
    restart: unless-stopped
    environment:
      POSTGRES_DB: paperless
      POSTGRES_USER: paperless
      POSTGRES_PASSWORD: paperless
    volumes:
      - ./pgdata:/var/lib/postgresql/data

  webserver:
    image: ghcr.io/paperless-ngx/paperless-ngx:latest
    restart: unless-stopped
    depends_on:
      - db
      - broker
    ports:
      - "8000:8000"
    env_file: docker-compose.env
    environment:
      PAPERLESS_REDIS: redis://broker:6379
      PAPERLESS_DBHOST: db
    volumes:
      - ./data:/usr/src/paperless/data
      - ./media:/usr/src/paperless/media
      - ./export:/usr/src/paperless/export
      - ./consume:/usr/src/paperless/consume

 
# In docker-compose.env:


###############################################################################
# Paperless-ngx settings                                                      #
###############################################################################
# UID and GID of your Linux user (default is 1000 for the first user)
USERMAP_UID=1000
USERMAP_GID=1000
# If exposing via a domain (e.g., through Cloudflare Tunnel or Caddy/Nginx):
PAPERLESS_URL=https://paperless.mydomain.com
PAPERLESS_ALLOWED_HOSTS=paperless.mydomain.com,server_ip,localhost,127.0.0.1
PAPERLESS_CSRF_TRUSTED_ORIGINS=https://paperless.mydomain.com,http://server_ip:8000
# IMPORTANT: Generate a long random secret (e.g., openssl rand -base64 64)
PAPERLESS_SECRET_KEY=generated_secret
# Timezone
PAPERLESS_TIME_ZONE=Asia/Kolkata
# OCR language (default: English)
PAPERLESS_OCR_LANGUAGE=eng
# Additional OCR languages (optional, space-separated codes)
# PAPERLESS_OCR_LANGUAGES=eng deu fra spa
    

12. Stirling PDF for converthing pdfs, docs, etc

name: "stirling-pdf"

services:
  stirling-pdf:
    image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest
    container_name: stirling-pdf
    ports:
      - '8085:8080'  # Using 8085 since 8080-8084 are in use
    volumes:
      - ./trainingData:/usr/share/tessdata  # OCR language data
      - ./configs:/configs                   # Configuration files
      - ./customFiles:/customFiles          # Custom static files (logos, etc)
      - ./logs:/logs                        # Application logs
      - ./pipeline:/pipeline                # Automated pipeline folders
    environment:
      - DISABLE_ADDITIONAL_FEATURES=false   # Enable all features
      - LANGS=en_GB                         # Default language
      - DOCKER_ENABLE_SECURITY=false        # Set to true if you want login
      - INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false
      - SYSTEM_DEFAULTLOCALE=en-US
      - UI_APPNAME=Stirling PDF
      - UI_HOMEDESCRIPTION=Self-hosted PDF toolkit
      - UI_APPNAVBARNAME=Stirling PDF
      - SECURITY_ENABLELOGIN=false          # Set to true to enable user login
      - SYSTEM_MAXFILESIZE=2000             # Max file size in MB
      - METRICS_ENABLED=true
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080"]
      interval: 30s
      timeout: 10s
      retries: 3

Docker services completed.

Cloudflare tunnel setup.

Buy domain from hostinger.in, just 125Rs for some domains. But 3000Rs per year from second year. If have budget buy for 3 years . If not, have to change to new domain after 1 year.

Add domain in cloudflare dashboard.

Change DNS records Name servers in hostinger(based on in your cloudflare dashboard after adding domain there):

cx****v.ns.cloudflare.com

j****e.ns.cloudflare.com

Install cloudflare tunnel on homeserver:

Got to one.dash.cloudflare.com -> Networks -> Connectors -> Overview.

Instructions to install cloudflare tunnel on debian.

In the same page, go to published application routes, add subdomains:

Click "Add a published application route"

enter subdomain , like ntfy, select domain in dropdown menu, select http in type in service, enter local url of server with correct port. Click save.

Repeat the same for all the services to be behind cloudflare tunnel.

To get ports of services running, run docker ps from anywhere on the terminal.

You can put jellyfin, immich behind both cloudflare tunnel and nginx. just use different subdomain name, like jellyfin.mydomain.com for nginx and tv.mydomain.com for cloudflare tunnel.

More security:

Block other countries IPs in cloudflare dashboard from accessing.

Go to dash.cloudflare.com -> select your domain -> Security > security rules.

Click on create rule -> custom rule

Enter name soemthing like "block other countries"

field: country

Operator: dees not equal

Value: India

Choose action: Block

Click save.

This will block all other countries IPs

If you are ok with cloudflare tunnel for immich, jellyfin and openspeedtest, you can skip this. But it is less snappier and also violates cloudflare tos.

Now. Nginx setup for Immich, jellyfin and openspeedtest.

This is slightly complex. Also this won't work if you are behind cg-nat(Public IP is shared by many residents)

If have public dynamic IP, need to use some dyndns. No-ip.com works and has template to my router, so use it.

If you have static public IP, no need of dyndns, directly point to your public IP in cloudflare dns records instead of dyndns

Create free dyndns in no-ip.com and get address and paste it in your router.

In cloudflare dash board dns records in mydomain.com, add this:

Type: CNAME

Name: jellyfin

IPv4: add the dyndns from no-ip(or your public IP incase of static IP)

Turn off Proxy(should be greyed. So this is just for DNS lookup) .

Click save. Do the same for openspeedtest and immich.

You need to confirm the hostname every 30 days (from email link) for free accounts.

Now, open ports 8443 on router and point to home server ip.

To check if opened port is working, use portchecker io to see while running a small python script which runs a dummy server(you can get from claude. Just 5-10 lines of code). if the portchecker says open, it is working.

First get dns_cloudflare_api_token :

dash.cloudflare.com -> my profile -> Api tokens -> create token

configure token -> edit zone dns -> use template

Zone -> DNS -> Edit

Include: Specific zone-> Select mydomain.com

Click Continue to summary

Click Create Token

Copy token, this is dns_cloudflare_api_token.

Nginx setup:

sudo apt install nginx certbot python3-certbot-dns-cloudflare
#below two not necessary as nginx creates by default
sudo mkdir -p /etc/nginx/sites-available
sudo mkdir -p /etc/nginx/sites-enabled


sudo mkdir -p /etc/letsencrypt
sudo nano /etc/letsencrypt/cloudflare.ini

#add this line: 
dns_cloudflare_api_token = YOUR_TOKEN_HERE #api token got from cloudflare dashboard

sudo chmod 600 /etc/letsencrypt/cloudflare.ini


sudo certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d immich.mydomain.com
  

sudo certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d jellyfin.mydomain.com
  
  
sudo certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d fast.mydomain.com

if you need nginx geoblocking for Other countries IPs:

sudo apt update
sudo apt install -y nginx libnginx-mod-http-geoip2

ls /usr/lib/nginx/modules | grep geoip

sudo mkdir -p /usr/share/GeoIP
cd /tmp
wget https://git.io/GeoLite2-Country.mmdb
sudo mv GeoLite2-Country.mmdb /usr/share/GeoIP/GeoLite2-Country.mmdb
sudo chmod 644 /usr/share/GeoIP/GeoLite2-Country.mmdb


sudo nano /etc/nginx/nginx.conf

#At the very top of the file (outside http {}):
load_module modules/ngx_http_geoip2_module.so;

#Add inside the http {} block

# GeoIP2 Configuration
geoip2 /usr/share/GeoIP/GeoLite2-Country.mmdb {
    $geoip2_data_country_iso_code country iso_code;
}

# Map Country Code to Allowed Variable
map $geoip2_data_country_iso_code $allowed_country {
    default no;
    IN yes;      # India allowed
}

# Optional: Enhanced Logging with Country Code
log_format geoip  '$remote_addr - $remote_user [$time_local] "$request" '
                  '$status $body_bytes_sent "$http_referer" '
                  '"$http_user_agent" "$http_x_forwarded_for" '
                  'Country:$geoip2_data_country_iso_code';

access_log /var/log/nginx/access.log geoip;


#Also add this to end of nginx.conf for better streaming of jellyfin :

include /etc/nginx/sites-enabled/*;
types_hash_max_size 2048;

Final /etc/nginx/nginx.conf:


#user http;
worker_processes  1;

#error_log  logs/error.log;
#error_log  logs/error.log  notice;
#error_log  logs/error.log  info;

#pid        logs/nginx.pid;


# Load all installed modules
include modules.d/*.conf;

#load_module "/usr/lib/nginx/modules/ngx_http_geoip2_module.so";

events {
    worker_connections  1024;
}


http {

    # GeoIP2 Configuration
    geoip2 /usr/share/GeoIP/GeoLite2-Country.mmdb {
        $geoip2_data_country_iso_code country iso_code;
    }

    # Map Country Code to Allowed Variable
    map $geoip2_data_country_iso_code $allowed_country {
        default no;
        IN yes;      # India allowed
    }
  
    include       mime.types;
    default_type  application/octet-stream;

    #log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
    #                  '$status $body_bytes_sent "$http_referer" '
    #                  '"$http_user_agent" "$http_x_forwarded_for"';

    #access_log  logs/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    #keepalive_timeout  0;
    keepalive_timeout  65;

    #gzip  on;

    server {
        listen       80;
        server_name  localhost;

        #charset koi8-r;

        #access_log  logs/host.access.log  main;

        location / {
            root   /usr/share/nginx/html;
            index  index.html index.htm;
        }

        #error_page  404              /404.html;

        # redirect server error pages to the static page /50x.html
        #
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   /usr/share/nginx/html;
        }

        # proxy the PHP scripts to Apache listening on 127.0.0.1:80
        #
        #location ~ \.php$ {
        #    proxy_pass   http://127.0.0.1;
        #}

        # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
        #
        #location ~ \.php$ {
        #    root           html;
        #    fastcgi_pass   127.0.0.1:9000;
        #    fastcgi_index  index.php;
        #    fastcgi_param  SCRIPT_FILENAME  /scripts$fastcgi_script_name;
        #    include        fastcgi_params;
        #}

        # deny access to .htaccess files, if Apache's document root
        # concurs with nginx's one
        #
        #location ~ /\.ht {
        #    deny  all;
        #}
    }


    # another virtual host using mix of IP-, name-, and port-based configuration
    #
    #server {
    #    listen       8000;
    #    listen       somename:8080;
    #    server_name  somename  alias  another.alias;

    #    location / {
    #        root   html;
    #        index  index.html index.htm;
    #    }
    #}


    # HTTPS server
    #
    #server {
    #    listen       443 ssl;
    #    server_name  localhost;

    #    ssl_certificate      cert.pem;
    #    ssl_certificate_key  cert.key;

    #    ssl_session_cache    shared:SSL:1m;
    #    ssl_session_timeout  5m;

    #    ssl_ciphers  HIGH:!aNULL:!MD5;
    #    ssl_prefer_server_ciphers  on;

    #    location / {
    #        root   html;
    #        index  index.html index.htm;
    #    }
    #}
  
    include /etc/nginx/sites-enabled/*;
    types_hash_max_size 2048;

}

Nginx setup for each service.

Immich:

#add to /etc/nginx/sites-available/immich

upstream immich_backend {
    server 127.0.0.1:2283;
    keepalive 32;
}

server {
    listen 8443 ssl;
    http2 on;
    server_name immich.mydomain.com;
  
    # Geo-Blocking Add this block only after installing geo-db blocking from apt
    if ($allowed_country = no) {
        return 444; # Drop connection immediately without response
    }

    ssl_certificate /etc/letsencrypt/live/immich.mydomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/immich.mydomain.com/privkey.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;
    ssl_ciphers HIGH:!aNULL:!MD5;
    ssl_session_timeout 1d;
    ssl_session_cache shared:MozSSL:10m;
    ssl_session_tickets off;

    ssl_stapling on;
    ssl_stapling_verify on;

    resolver 1.1.1.1 1.0.0.1 valid=300s;
    resolver_timeout 5s;

    add_header X-Frame-Options SAMEORIGIN always;
    add_header X-Content-Type-Options nosniff always;
    add_header Referrer-Policy no-referrer-when-downgrade always;

    client_max_body_size 20G;

    proxy_read_timeout 600s;
    proxy_send_timeout 600s;

    proxy_request_buffering off;

    proxy_http_version 1.1;
    proxy_set_header Connection "";
    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;

    proxy_buffering off;

    location / {
        proxy_pass http://immich_backend;
    }

    location /socket.io/ {
        proxy_pass http://immich_backend;
        proxy_http_version 1.1;

        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        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;
    }
}

Jellyfin:

#add to /etc/nginx/sites-available/jellyfin
upstream jellyfin_backend {
    server 127.0.0.1:8096;
    keepalive 32;
}

server {
    listen 8443 ssl;
    http2 on;
    server_name jellyfin.mydomain.com;

    # Geo-Blocking Add this block only after installing geo-db blocking from apt
    if ($allowed_country = no) {
        return 444; 
    }

    ssl_certificate /etc/letsencrypt/live/jellyfin.mydomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/jellyfin.mydomain.com/privkey.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:HIGH:!aNULL:!MD5;
    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:50m;
    ssl_session_tickets on;

    client_max_body_size 0;

    proxy_buffering off;
    proxy_request_buffering off;

    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
    send_timeout 3600s;

    sendfile on;
    sendfile_max_chunk 2m;
    tcp_nopush on;
    tcp_nodelay on;

    keepalive_timeout 120;
    keepalive_requests 1000;

    location / {
        proxy_pass http://jellyfin_backend;
        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;
    }

    location ~ /(socket|api/websocket) {
        proxy_pass http://jellyfin_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 24h;
        proxy_send_timeout 24h;
        access_log off;
    }

    location /web/ {
        proxy_pass http://jellyfin_backend;
        proxy_http_version 1.1;
        proxy_cache_valid 200 1h;
        proxy_cache_bypass $http_upgrade;

        expires 1h;
        add_header Cache-Control "public";
    }

    location ~* \.(mp4|mkv|avi|mov|wmv|flv|webm|m4v|3gp|ts|m3u8)$ {
        proxy_pass http://jellyfin_backend;
        proxy_http_version 1.1;
        proxy_set_header Range $http_range;
        proxy_set_header If-Range $http_if_range;

        proxy_buffering off;
        proxy_request_buffering off;

        sendfile on;
        sendfile_max_chunk 10m;
        tcp_nopush on;

        access_log off;
    }
}

Openspeedtest:

#add to /etc/nginx/sites-available/fast
upstream fast_backend {
    server 127.0.0.1:3000;
    keepalive 32;
}

server {
    listen 8443 ssl; 
    # REMOVED: http2 on; <--- This is the culprit for the 5Gbps fake speed
    server_name fast.mydomain.com;
 
    # Geo-Blocking. Add this block only after installing geo-db blocking from apt
    if ($allowed_country = no) {
        return 444; 
    }

    ssl_certificate /etc/letsencrypt/live/fast.mydomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/fast.mydomain.com/privkey.pem;

    # ==========================================
    # SPEED TEST SPECIFIC SETTINGS
    # ==========================================

    # 1. Disable Gzip specifically for this site
    gzip off;

    # 2. Allow unlimited upload size
    client_max_body_size 0;

    # 3. Disable all Nginx Buffering (Force Direct Stream)
    proxy_request_buffering off;
    proxy_buffering off;

    # 4. Standard Proxy Headers
    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;

    # 5. Optimization
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;

    location / {
        proxy_pass http://fast_backend;

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

sudo ln -s /etc/nginx/sites-available/immich /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/jellyfin /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/fast /etc/nginx/sites-enabled/

sudo nginx -t
sudo systemctl enable nginx
sudo systemctl start nginx
sudo systemctl status nginx

If client is also linux(here arch), setup NFS for easy access(SMB for windows) . It is mounted at ~/server

#on server
sudo apt update
sudo apt install -y nfs-kernel-server

sudo nano /etc/exports
/home/server 192.168.0.0/24(rw,sync,crossmnt,no_subtree_check,all_squash,anonuid=1000,anongid=1000)

sudo exportfs -arv
sudo systemctl enable --now nfs-server

exportfs -v

sudo ufw allow from 192.168.0.0/24 to any port nfs
sudo ufw allow from 192.168.0.0/24 to any port 2049
sudo ufw allow from 192.168.0.0/24 to any port 111
sudo ufw reload


sudo ufw status


#On client(laptop/desktop). Here it is for arch linux: 
sudo pacman -S nfs-utils
mkdir -p ~/server
sudo nano /etc/fstab
server_ip:/home/server /home/client/server nfs \
_netdev,noauto,x-systemd.automount,x-systemd.mount-timeout=10,timeo=14,x-systemd.idle-timeout=1min 0 0


sudo systemctl daemon-reload
sudo systemctl restart remote-fs.target

Optional fun: Ntfy notifications of blocked IPs:

# 1. Action File (/etc/fail2ban/action.d/ntfy.conf)
# Note: Ensure both Ban and Unban URLs use the SAME topic!
[Definition]
actionban = curl -H "Title: Fail2Ban Alert" -H "Priority: high" -H "Tags: warning" -d "Banned IP: <ip> (GeoIP Blocked) - Lookup: https://db-ip.com/<ip>" https://ntfy.mydomain.com/security-alerts-supersecurepassphrase

actionunban = curl -H "Title: Fail2Ban Info" -H "Tags: information_source" -d "Unbanned IP: <ip>" https://ntfy.mydomain.com/security-alerts-supersecurepassphrase

# 2. Filter File (/etc/fail2ban/filter.d/nginx-444.conf)
[Definition]
failregex = ^<HOST> -.*"(GET|POST|HEAD).*HTTP.*" 444 0 .*$
ignoreregex =

# 3. Jail Configuration (/etc/fail2ban/jail.local)
# CAUTION: Create /var/log/nginx/error.log if it's missing to prevent crashes!
[DEFAULT]
# Global action: Block ports + Send Notification
action = iptables-allports
         ntfy

[nginx-geoip]
enabled  = true
port     = http,https,8443
filter   = nginx-444
logpath  = /var/log/nginx/access.log
maxretry = 1
bantime  = 86400
action   = iptables-allports[name=nginx-geoip]
           ntfy

[nginx-http-auth]
enabled = true
logpath = /var/log/nginx/error.log

[nginx-botsearch]
enabled = true
logpath = /var/log/nginx/access.log