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 sshMounting 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):
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 nginxIf 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