wiki
Arch
- [[linux/desktop/arch/software]]
- [[linux/desktop/arch/archinstall]]
- [[linux/desktop/arch/terminal-setup]]
- [[linux/desktop/arch/addition-package-managers]]
- [[linux/desktop/arch/ssh]]
firmware updates:
archinstall
archinstall config with sensible settings
{
"!version": "2.8.0",
"locale_config": {
"sys_enc": "en_BE.UTF-8",
"encoding": "UTF-8"
},
"keyboard_layout": "be-latin1",
"timezone": "Europe/Brussels",
"hostname": "TnT-arch-[CHANGEME]",
"mirror_config": {
"mirror_regions": {
"Worldwide": []
}
},
"parallel_downloads": 5,
"bootloader": "systemd-boot",
"kernel": [
"linux"
],
"disk_config": {
"config_type": "default_layout",
"device_modifications": [
{
"device": "/dev/sda",
"wipe": true,
"partitions": [
{
"btrfs": [],
"flags": [
"Boot"
],
"fs_type": "fat32",
"length": {
"sector_size": null,
"total_size": null,
"unit": "MiB",
"value": 512
},
"mount_options": [],
"mountpoint": "/boot",
"obj_id": "efi",
"start": {
"sector_size": null,
"total_size": null,
"unit": "MiB",
"value": 1
},
"status": "create",
"type": "primary"
},
{
"btrfs": [],
"flags": [],
"fs_type": "linuxswap",
"length": {
"sector_size": null,
"total_size": null,
"unit": "GiB",
"value": 4
},
"mount_options": [],
"mountpoint": null,
"obj_id": "swap",
"start": {
"sector_size": null,
"total_size": null,
"unit": "MiB",
"value": 513
},
"status": "create",
"type": "primary"
},
{
"btrfs": [],
"flags": [],
"fs_type": "ext4",
"length": {
"sector_size": null,
"total_size": null,
"unit": "percent",
"value": 100
},
"mount_options": [],
"mountpoint": "/",
"obj_id": "root",
"start": {
"sector_size": null,
"total_size": null,
"unit": "GiB",
"value": 4
},
"status": "create",
"type": "primary"
}
]
}
]
},
"profile_config": {
"profile": {
"classtype": "DesktopProfile",
"name": "kde"
},
"gfx_driver": null,
"greeter": "sddm"
},
"audio_config": {
"audio": "pipewire"
},
"network_config": {
"type": "nm",
"iface": "NetworkManager"
},
"packages": [],
"root_enc_password": "CHANGEME",
"!users": [
{
"username": "syndias",
"password": "CHANGEME",
"sudo": true
}
]
}
ssh setup
2. SSH
sudo pacman -S openssh
sudo systemctl start sshd
sudo systemctl enable sshd
terminal setup
- [[linux/desktop/arch/terminal-setup/bashrc]]
- [[linux/desktop/arch/terminal-setup/fastfetch]]
- [[linux/desktop/arch/terminal-setup/starship]]
3. terminal setup (nerd-fonts, fastfetch, starship)
Bash
add the following to /etc/inputrc:
set completion-ignore-case on
nerd-fonts
sudo pacman -S nerd-fonts
FastFetch
sudo pacman -S fastfetch
add the following to ~/.bashrc
fastfetch
Starship
curl -sS https://starship.rs/install.sh | sh
add the following to ~/.bashrc
eval "$(starship init bash)"
.bashrc
fastfetch
eval "$(starship init bash)"
# If not running interactively, don't do anything
[[ $- != *i* ]] && return
alias ls='ls --color=auto'
alias grep='grep --color=auto'
PS1='[\u@\h \W]\$ '
export PATH="$PATH:$HOME/.local/bin"
export PATH="$PATH:$HOME/Apps"
fastfetch.json
// Load with --load-config examples/2.jsonc
// Note that you must replace the image path to an existing image to display it.
{
"$schema": "https://github.com/fastfetch-cli/fastfetch/raw/dev/doc/json_schema.json",
// "logo": {
// "type": "iterm",
// "source": "/Users/carter/Desktop/apple1.png",
// "width": 28,
// "height": 12
// },
"display": {
"separator": " ",
"bar": {"border": {
}, "width": 10}
},
"general": {
},
"modules": [
{
"type": "custom", // HardwareStart,
"keyColor": "dim_",
"key": "┌──────────────────── Hardware ──────────────────────┐",
"format": ""
},
{
"type": "custom",
"key": "\udb80\udf22 PC :",
"format": "TnT-Desktop", // `\u001b` is `\033`, or `\e`,
"keyColor": "green"
},
{
"type": "cpu",
"key": "│ ├ :",
"keyColor": "green"
},
{
"type": "gpu",
"key": "│ ├ :",
"keyColor": "green"
},
{
"type": "memory",
"key": "└ └ :",
"format": "{1} / {2}",
"keyColor": "green"
},
{
"type": "custom", // InformationEnd
"key": "└────────────────────────────────────────────────────┘",
"keyColor": "dim_",
"format": ""
},
{
"type": "custom", // SoftwareStart
"key": "┌──────────────────── Software ──────────────────────┐",
"keyColor": "dim_",
"format": ""
},
{
"type": "os",
"keyColor": "blue",
"key": "\ue70f OS :" // Just get your distro's logo off nerdfonts.com
},
{
"type": "title",
"color": {"host": "red", "user": "blue"},
"keyColor": "blue",
"key": "│ ├\uf007 :"
},
{
"type": "kernel",
"key": "│ ├ :",
"keyColor": "blue",
"format": "{1} {2}"
},
{
"type": "de",
"keyColor": "blue",
"key": "│ ├ :"
},
{
"type": "packages",
"keyColor": "blue",
"key": "└ └ :"
},
{
"type": "custom", // InformationEnd
"key": " ",
"format": ""
},
{
"type": "localip",
"key": "│ ├ :",
"keyColor": "yellow",
"compact": true
},
{
"type": "publicip",
"key": "└ └ :",
"keyColor": "yellow",
"format": "{1}"
},
{
"type": "custom", // InformationEnd
"key": "└────────────────────────────────────────────────────┘",
"keyColor": "dim_",
"format": ""
},
{
"type": "custom", // SoftwareStart
"key": "┌───────────────────── Uptime ───────────────────────┐",
"keyColor": "dim_",
"format": ""
},
{
"type": "uptime",
"keyColor": "magenta",
"key": " Uptime :"
},
{
"type": "custom", // InformationEnd
"key": "└────────────────────────────────────────────────────┘",
"keyColor": "dim_",
"format": ""
},
// {
// "type": "colors",
// "paddingLeft": 2
// }
]
}
starship.toml
format = """
[](#3B4252)\
$python\
$username\
[](bg:#434C5E fg:#3B4252)\
$directory\
[](fg:#434C5E bg:#4C566A)\
$git_branch\
$git_status\
[](fg:#4C566A bg:#86BBD8)\
$c\
$elixir\
$elm\
$golang\
$haskell\
$java\
$julia\
$nodejs\
$nim\
$rust\
[](fg:#86BBD8 bg:#06969A)\
$docker_context\
[](fg:#06969A bg:#33658A)\
$time\
[ ](fg:#33658A)\
"""
command_timeout = 5000
# Disable the blank line at the start of the prompt
# add_newline = false
# You can also replace your username with a neat symbol like to save some space
[username]
show_always = true
style_user = "bg:#3B4252"
style_root = "bg:#3B4252"
format = '[$user ]($style)'
[directory]
style = "bg:#434C5E"
format = "[ $path ]($style)"
truncation_length = 3
truncation_symbol = "…/"
# Here is how you can shorten some long paths by text replacement
# similar to mapped_locations in Oh My Posh:
[directory.substitutions]
"Documents" = " "
"Downloads" = " "
"Music" = " "
"Pictures" = " "
# Keep in mind that the order matters. For example:
# "Important Documents" = " "
# will not be replaced, because "Documents" was already substituted before.
# So either put "Important Documents" before "Documents" or use the substituted version:
# "Important " = " "
[c]
symbol = " "
style = "bg:#86BBD8"
format = '[ $symbol ($version) ]($style)'
[docker_context]
symbol = " "
style = "bg:#06969A"
format = '[ $symbol $context ]($style) $path'
[elixir]
symbol = " "
style = "bg:#86BBD8"
format = '[ $symbol ($version) ]($style)'
[elm]
symbol = " "
style = "bg:#86BBD8"
format = '[ $symbol ($version) ]($style)'
[git_branch]
symbol = ""
style = "bg:#4C566A"
format = '[ $symbol $branch ]($style)'
[git_status]
style = "bg:#4C566A"
format = '[$all_status$ahead_behind ]($style)'
[golang]
symbol = " "
style = "bg:#86BBD8"
format = '[ $symbol ($version) ]($style)'
[haskell]
symbol = " "
style = "bg:#86BBD8"
format = '[ $symbol ($version) ]($style)'
[java]
symbol = " "
style = "bg:#86BBD8"
format = '[ $symbol ($version) ]($style)'
[julia]
symbol = " "
style = "bg:#86BBD8"
format = '[ $symbol ($version) ]($style)'
[nodejs]
symbol = ""
style = "bg:#86BBD8"
format = '[ $symbol ($version) ]($style)'
[nim]
symbol = " "
style = "bg:#86BBD8"
format = '[ $symbol ($version) ]($style)'
[python]
style = "bg:#3B4252"
format = '[(\($virtualenv\) )]($style)'
[rust]
symbol = ""
style = "bg:#86BBD8"
format = '[ $symbol ($version) ]($style)'
[time]
disabled = false
time_format = "%R" # Hour:Minute Format
style = "bg:#33658A"
format = '[ $time ]($style)'
package managers
A. Additional Package Managers
YAY AUR Helper
CTT Linux tool, go to Arch > Yay AUR Helper
curl -fsSL https://christitus.com/linux | sh
Flatpak / Flathub
sudo pacman -S flatpak
Snap
yay -S snapd
systemctl enable snapd
reboot
sudo ln -s /var/lib/snapd/snap /snap
software
- [[linux/desktop/arch/software/vscode]]
- [[linux/desktop/arch/software/yubikey]]
- [[linux/desktop/arch/software/docker]]
- [[linux/desktop/arch/software/brave-browser]]
docker
Docker
CTT Linux tool, go to Applications Setup > Docker
curl -fsSL https://christitus.com/linux | sh
``
vscode
sudo snap install code --classic
Setup Yubikey Authenticator on Arch
sudo pacman -S pcsclite ccid pcsc-tools yubikey-personalization
sudo systemctl enable --now pcscd
sudo udevadm control --reload-rules
sudo udevadm trigger
sudo systemctl restart pcscd
flatpak override --user --filesystem=/run/pcscd --device=all com.yubico.yubioath
flatpak run com.yubico.yubioath
brave
Browser (Brave)
curl -fsS https://dl.brave.com/install.sh | sh
Retro Gaming Setup
RetroArch
Converting eXoDOS games for RetroArch (DOSBox-Pure)
This guide covers taking games from an eXoDOS collection and converting them into a format that works with the DOSBox-Pure core in RetroArch, for use on devices like the Miyoo Mini Plus or in RomM.
1. Get eXoDOS installed
You need a working eXoDOS install before converting anything.
- Full downloads every game’s actual files upfront (hundreds of GB to a few TB depending on version and add-ons). Fully offline once downloaded.
- Lite downloads just the frontend and metadata, then fetches each game’s zip on demand the first time you install it. Faster to get running if you already know which games you want, but needs internet access at install time for each game.
If you already have a curated list of games in mind, Lite is the more practical choice, since ExoDOSConverter supports pulling games from a Lite install on the fly.
On Linux (Arch, etc.)
There is now an official Linux Patch that installs eXoDOS natively (no Wine needed for the collection itself):
- Download the base eXoDOS collection and the separate
eXoDOS Linux Patch.zipfrom retro-exo.com. - Extract the Linux Patch zip into the same root folder as your eXoDOS download (where
Setup eXoDOS.batandeXoDOS Catalog.pdflive). Files should land directly in that root, not a subfolder. - Install
flatpakfirst if you don’t have it:sudo pacman -S flatpak(log out and back in afterward). - Run
install_dependencies.command, choose Proceed with installation. This pulls in DOSBox Staging, DOSBox-X, DOSBox ECE, ScummVM, and a Wine flatpak for edge cases via flatpak. - Run
Setup eXoDOS.command(not the.batfile). Follow the prompts for fullscreen/windowed, aspect ratio, and adult content filtering. - Launch
exogui(either from the desktop shortcut it creates, orexogui.commanddirectly) to browse and install games.
2. Curate your game selection
Decide which games you actually want before converting. eXoDOS is enormous (thousands of titles), and the Miyoo Mini Plus’s ARM Cortex-A7 at 1.2GHz handles early, simpler DOS games fine but struggles with anything doing heavy 3D or software rendering. Test a couple of your heavier picks early rather than converting everything and finding out later.
3. Set up ExoDOSConverter
ExoDOSConverter repackages selected eXoDOS games into a format DOSBox-Pure understands (Batocera, RetroPie, EmuELEC, RetroBat, MiSTer targets). Miyoo isn’t a listed target directly, use Batocera or RetroPie as the output format, since both produce lr-dosbox-pure-compatible output.
Installing on Arch Linux
git clone https://github.com/Voljega/ExoDOSConverter
cd ExoDOSConverter
Install dependencies. The pinned versions in requirements.txt are old (2021-2022) and may fail to build on newer Python (e.g. Pillow 9.0.1 fails on Python 3.14 with a setuptools incompatibility). Instead, install current versions directly:
pip install chardet certifi charset-normalizer idna pillow --break-system-packages
(Use a virtualenv instead of --break-system-packages if you’d rather not touch system Python packages.)
Known fix needed on Linux
The converter’s dosbox-pure zip step uses a Windows-only text encoding (mbcs) that doesn’t exist on Linux, causing:
LookupError: unknown encoding: mbcs
Fix by patching the source:
sed -i 's/encoding="mbcs"/encoding="cp1252"/' gamegenerator.py
This substitutes the closest real equivalent (cp1252, Windows’ typical default ANSI codepage). If a UnicodeDecodeError shows up instead after this fix (rare), try latin-1, which never fails to decode but can occasionally misrender rare characters.
4. Run the conversion
- Launch the tool:
./ExoDOSConverter.sh - Point collectionDir at your installed eXoDOS folder.
- Set conversionType to Batocera or RetroPie.
- Select your curated games from the list (or load/save a
.selselection file for reuse). - Run the conversion.
For each game, expect:
- A
.zipfile (the game itself, packaged for DOSBox-Pure’s zip-loading support) - A
.keysfile (a gamepad-to-keyboard mapping, generated from the Keyb2Joypad Project database)
A <WARNING> game uses call run.bat message during conversion is informational, not an error. It just means the game’s launcher chains into a second batch file rather than being self-contained, worth a quick test on that specific game afterward since chained-launcher games are more likely to have a subtly wrong launch command.
If a game errors out, check error_log.txt in your output directory for the full traceback, the console only prints the error type.
5. Deploy to your device
- Copy the converted game folders/zips onto your SD card, into whatever folder your firmware uses for DOS/PC content (OnionOS-based Miyoo builds typically use
Roms/DOS). - Keep each
.keysfile in the same folder as its matching.zip, same base filename. - Make sure DOSBox-Pure is the assigned core for DOS content in RetroArch.
- If using zipped games, enable Core Options > Emulation > Loading of dosbox.conf so RetroArch reads the bundled config.
- Skip over any Batocera/RetroPie-specific
gamelist.xmlor scraper metadata, your device’s frontend won’t read those the same way.
Note on RomM
RomM’s own documentation only mentions .conf files being auto-detected for mounting, it doesn’t document .keys file support. DOSBox-Pure itself has since gained built-in automatic controller mapping (from the same Keyb2Joypad database), so many games may map correctly without the .keys file being used at all. Test a game or two, check RetroArch’s Controller Mapper screen for “Mapping:
Known incompatibilities
- Games whose original DOSBox config relies on the
mountcommand are a known incompatibility with DOSBox-Pure at this time.
Framework Laptop 16 — Firmware Update Guide (Arch Linux)
Applies to: Framework Laptop 16 (AMD Ryzen 7040 Series) running Arch Linux
Updated: April 2026
Current stable BIOS: 4.03
Overview
There are two separate firmware components to update:
- Input module firmware (keyboard, numpad, macropad) — must be done first
- BIOS/UEFI — staged via fwupd, applied on reboot
Both can be handled through fwupdmgr if you’re on fwupd 2.0.16 or later.
Prerequisites
1. Check your current BIOS version
sudo dmidecode -s bios-version
If you’re on anything older than 4.03, this guide applies to you.
2. Check your fwupd version
fwupdmgr --version
Look for org.freedesktop.fwupd — you need 2.0.16 or later. On Arch, you almost certainly have a newer version. If you’re behind, update first:
sudo pacman -Syu fwupd
3. Ensure fwupd is installed
sudo pacman -S fwupd
4. Plug in your charger
The update will fail if you’re not on AC power. Additionally, if your battery is at 100%, LVFS may refuse to update (it checks charging status). If that happens, discharge a few percent, plug back in, then proceed.
Step 1 — Refresh firmware metadata
fwupdmgr refresh --force
This pulls the latest metadata from LVFS (Linux Vendor Firmware Service), including Framework’s stable firmware releases.
Step 2 — Check available updates
fwupdmgr get-updates
You should see updates listed for:
- One or more input modules (keyboard, numpad, macropad) — firmware 0.3.1
- Framework Laptop 16 System Firmware — BIOS 4.03
Note: If you only see the BIOS update and not the keyboard firmware, it may mean your keyboard modules are already up to date, or that fwupd hasn’t detected them yet. Try unplugging and replugging the laptop’s power, then re-run
fwupdmgr refresh --force.
Step 3 — Apply all updates
fwupdmgr update
What happens:
- Input module firmware flashes immediately and inline — no reboot required for this step.
- BIOS update is staged for the next reboot. fwupd will tell you to restart.
⚠️ Do not interrupt either flash. Do not close the lid, unplug the charger, or force-power-off during this process.
Step 4 — Reboot
sudo reboot
After reboot, your system will enter a UEFI firmware update shell automatically. You’ll see a progress bar. The laptop will reboot a second time once flashing is complete — this is normal.
Do not power off during this process. The whole thing takes 1–3 minutes.
Step 5 — Verify the update
Once you’re back in Arch, confirm the new BIOS version:
sudo dmidecode -s bios-version
Expected output: 4.03
You can also verify via fwupd:
fwupdmgr get-devices
What’s new in BIOS 4.03 (vs 3.x)
| Fix / Feature | Details |
|---|---|
| CPU power table corrected | CPU was incorrectly capped at 35W under load |
| False “Fan Not Detected” error fixed | Spurious fan warning on boot eliminated |
| Lid-close wake fixed | Keyboard/input modules no longer wake the system when lid is closed |
| Dual-port adapter stability | 100W adapter no longer disconnects when switching ports |
| POST RAM test fixed | Now correctly validates more than 64GB of memory |
| PROCHOT throttling fixed | CPU no longer throttles unexpectedly under heavy load |
| NVIDIA RTX 5070 support added | Not relevant unless you have the GPU expansion module |
Troubleshooting
Update doesn’t show up in get-updates
- Run
fwupdmgr refresh --forceagain - Make sure you’re on AC power
- Check that the LVFS stable remote is enabled:
fwupdmgr get-remotes - If the
lvfsremote showsenabled: false, enable it:sudo fwupdmgr enable-remote lvfs
Battery at 100% — update refused
Discharge the battery to ~95%, plug back in, then retry:
fwupdmgr refresh --force
fwupdmgr update
Keyboard firmware update not detected
If fwupd doesn’t see your input modules, you can update them manually using the UF2 method. See the Framework keyboard firmware guide for instructions.
Downgrading
If you need to roll back after updating to 4.03, the only supported downgrade path is to BIOS 3.05 or newer. Downgrading to versions older than 3.05 is not supported.
References
- Framework 16 BIOS 4.03 Release Notes
- Framework Keyboard Firmware Update Guide
- ArchWiki — Framework Laptop 16
- fwupd on ArchWiki
Homelab
Homelab info
- [[homelab/README]]
- [[homelab/architecture]]
1-hetzner
- [[homelab/1-hetzner]]
2-truenas-homelab
- [[homelab/2-truenas-homelab]]
3-web-hosting
- [[homelab/3-web-hosting]]
4-services
- [[homelab/4-services]]
5-connections
- [[homelab/5-connections]]
6-backups
- [[homelab/6-backups]]
homelab-setup
Architecture Overview
graph TB
subgraph INTERNET["Internet"]
USER["Users / Web Browser"]
DEV["Developer"]
MOBILE["Remote Clients\nLaptop / Mobile"]
GH[("GitHub")]
HC[("Healthchecks.io")]
end
subgraph VPS["Hetzner VPS — CX43 · Ubuntu 24.04 · Falkenstein"]
TRAEFIK["Traefik\nReverse Proxy + Let's Encrypt"]
subgraph VPS_WEB["Web Hosting"]
NGINX["Nginx\nStatic Sites"]
DECAP["Decap OAuth\nCMS Auth Provider"]
end
subgraph VPS_AUTH["Identity & Auth"]
AUTHENTIK["Authentik\nSSO / OIDC"]
AUTH_DB[("PostgreSQL")]
AUTH_REDIS[("Redis")]
end
subgraph VPS_VPN["VPN & Tunneling"]
NETBIRD["Netbird Server\nManagement · Signal · Relay"]
PANGOLIN["Pangolin\nResource Gateway"]
GERBIL["Gerbil\nWireGuard Endpoint"]
WG_CLIENT["WireGuard Client\nBackhaul Tunnel"]
end
end
subgraph HOMELAB["TrueNAS Homelab — 192.168.50.203"]
DOCKGE["Dockge\nStack Manager"]
subgraph HL_DNS["DNS Stack"]
PIHOLE["Pi-hole\nDNS + Ad Blocker"]
UNBOUND["Unbound\nRecursive Resolver"]
end
subgraph HL_VPN["VPN Clients"]
WGEASY["wg-easy\nWireGuard Server"]
NEWT["Newt\nPangolin Peer"]
NB_GW["Netbird\nGateway Peer"]
end
subgraph HL_APPS["Applications"]
MC["Minecraft Server\nPaper · Java 21"]
BACKREST["Backrest\nRestic Backup"]
end
HL_STORAGE[("TrueNAS Storage\n/mnt/pool")]
end
subgraph OFFSITE["Off-site Storage"]
STORAGEBOX[("Hetzner Storage Box BX11\n1 TB · SFTP · Helsinki")]
end
%% Inbound HTTPS
USER -->|HTTPS :443| TRAEFIK
MOBILE -->|HTTPS :443| TRAEFIK
%% Traefik routing
TRAEFIK --> NGINX
TRAEFIK --> DECAP
TRAEFIK --> AUTHENTIK
TRAEFIK --> NETBIRD
TRAEFIK --> PANGOLIN
%% Authentik internals
AUTHENTIK --- AUTH_DB
AUTHENTIK --- AUTH_REDIS
%% Netbird SSO
NETBIRD -.->|SSO auth| AUTHENTIK
%% CI/CD pipeline
DEV -->|git push| GH
GH -->|SSH + rsync| NGINX
DECAP -->|OAuth2| GH
%% WireGuard backhaul VPS ↔ Homelab
WG_CLIENT <-->|WireGuard 10.8.0.0/24| WGEASY
%% Pangolin resource tunnel
PANGOLIN --- GERBIL
GERBIL <-->|WireGuard 100.89.128.0/24| NEWT
%% Netbird mesh VPN
MOBILE <-->|Netbird P2P / Relay| NB_GW
NB_GW -->|routes 192.168.50.0/24| PIHOLE
NB_GW -->|routes 192.168.50.0/24| MC
%% DNS chain
PIHOLE -->|port 5335| UNBOUND
UNBOUND -->|recursive lookup| INTERNET
%% Backup flows
BACKREST --- HL_STORAGE
BACKREST -->|SSHFS mount| VPS
BACKREST -->|SFTP :23| STORAGEBOX
BACKREST -.->|success ping| HC
%% Dockge manages stacks
DOCKGE -.->|manages| HL_DNS
DOCKGE -.->|manages| HL_VPN
DOCKGE -.->|manages| HL_APPS
Hetzner VPS Setup Guide - Configuration Decisions
Step 1: Go to Hetzner Cloud
- Navigate to https://console.hetzner.cloud/
- Log in or create an account
- Click “New Project” or select existing project
- Click “Add Server”
Step 2: Choose Location
Selection: Falkenstein, Germany (fsn1)
- Why: Closest to Belgium (~600km)
- Best latency (10-15ms)
- Hetzner’s flagship datacenter
Step 3: Choose Image (Operating System)
Selection: Ubuntu 24.04 LTS
- Why: Best Docker support, long-term updates until 2029
- Most tutorials/documentation available
- Modern packages, great community
Step 4: Choose Server Type
Selection: CX43 (Shared vCPU x86)
- 8 vCPU (AMD/Intel)
- 16GB RAM
- 160GB NVMe SSD storage
- Cost: ~€10-11/month
Step 5: Networking
Primary IPv4: Enable (Required)
- Cost: ~€0.72/month
- Why: Essential for websites, NextCloud, broad compatibility
- IPv6 included free automatically
Total networking cost: ~€0.72/month
Step 6: Volumes (Additional Storage)
Selection: None (skip for now)
- The 160GB built-in storage is sufficient initially
- Can add later if needed (€5/month for 100GB)
Step 7: Backups
Selection: Disabled (skip)
- Saves €2.20/month (20% of server cost)
- Why skip: Better to backup to your homeserver via WireGuard
- Doesn’t include volumes anyway
- Server is easily rebuildable
Step 8: SSH Keys
Important: Add your SSH key!
- Click “Add SSH Key”
- Paste your public key (from
~/.ssh/id_rsa.pubor~/.ssh/id_ed25519.pub) - If you don’t have one, generate it first:
ssh-keygen -t ed25519 -C "your_email@example.com"
- This enables secure, password-free login
Step 9: Labels
Selection: Leave empty
- Not needed for single server
- Can add later if you expand
Step 10: Cloud Config
Selection: Leave empty
- Manual setup is better for learning
- Easier to debug and understand
Step 11: Server Name
Suggestion: Choose a descriptive name
Examples:
homelab-vpsnextcloud-serverbelgium-main
Step 12: Review & Create
Final Configuration Summary:
- Location: Falkenstein (fsn1)
- OS: Ubuntu 24.04 LTS
- Type: CX43 (8 vCPU, 16GB RAM, 160GB storage)
- IPv4: Enabled (~€0.72/month)
- Backups: Disabled
- SSH Key: Added ✓
Total Cost: ~€11-12/month
Click “Create & Buy Now”
Step 13: Wait for Provisioning
- Server typically ready in 30-60 seconds
- You’ll see the IPv4 address appear
- Server status will change to “Running”
Step 14: Save Important Information
Write down:
- Server IPv4 address: (example: 123.45.67.89)
- Server name: (what you chose)
- Root password: (if you didn’t add SSH key - but you should have!)
SSH Login to Your Hetzner VPS
Step 1: Get Your Server’s IP Address
From the Hetzner Cloud Console, copy your server’s IPv4 address.
Example: 123.45.67.89
Step 2: Open Terminal
- Linux/macOS: Open Terminal
- Windows: Use PowerShell, Windows Terminal, or install WSL
Step 3: SSH Login Command
Basic Login
ssh root@YOUR_SERVER_IP
Replace YOUR_SERVER_IP with your actual IP address.
Example:
ssh root@123.45.67.89
First Time Connection
You’ll see a message like:
The authenticity of host '123.45.67.89' can't be established.
ED25519 key fingerprint is SHA256:xxxxxxxxxxxxxxxxxxxxx
Are you sure you want to continue connecting (yes/no/[fingerprint])?
Type yes and press Enter.
Step 4: Authentication
If You Added SSH Key (Recommended)
You’ll login automatically - no password needed! ✅
If You Didn’t Add SSH Key
You’ll be prompted for the root password. Find it in:
- Hetzner Cloud Console → Your Server → Access tab
- Email from Hetzner (sent when server was created)
Step 5: Verify You’re Logged In
After successful login, you’ll see:
root@your-server-name:~#
You’re now logged into your VPS as root! 🎉
Troubleshooting
Permission Denied (publickey)
Your SSH key isn’t configured. Either:
- Add your SSH key in Hetzner console and rebuild server
- Use password authentication if available
Connection Refused
- Server might still be starting (wait 30 seconds)
- Check if you’re using correct IP address
- Firewall might be blocking (unlikely on fresh Hetzner VPS)
Connection Timeout
- Check your internet connection
- Verify IP address is correct
- Try from different network (corporate firewalls sometimes block SSH)
Better SSH Configuration (Optional)
Create/edit ~/.ssh/config on your local machine:
Host homelab-vps
HostName 123.45.67.89
User root
IdentityFile ~/.ssh/id_ed25519
Then you can login simply with:
ssh homelab-vps
System Update and Initial Setup
Step 1: All-in-One Command
You can run all of these in sequence:
apt update && apt upgrade -y && apt full-upgrade -y && apt autoremove -y && apt clean
- update: This fetches the latest package information from Ubuntu’s repositories.
- upgrade: Download and install all available updates
- full-upgrade:
- Can remove obsolete packages
- Handles dependency changes better
- More thorough than basic upgrade
- autoremove: Cleans up packages that were installed as dependencies but are no longer needed.
- clean: Frees up disk space by removing downloaded package files.
Step 2: Check if Reboot is Needed
After updates, check if a reboot is required:
[ -f /var/run/reboot-required ] && echo "Reboot required" || echo "No reboot needed"
If Reboot is Required:
reboot
Your SSH connection will drop. Wait 30 seconds, then reconnect:
ssh root@YOUR_SERVER_IP
Verify System Info
After reboot (if needed), check your system:
uname -a
Shows kernel version and system info.
lsb_release -a
Confirms Ubuntu version (should show 24.04).
df -h
Shows disk usage (you should see plenty of free space on your 160GB).
Step 3: install Fast-Fetch
# Download the latest release for Linux amd64
wget https://github.com/fastfetch-cli/fastfetch/releases/latest/download/fastfetch-linux-amd64.deb
# Install the .deb package
apt install ./fastfetch-linux-amd64.deb -y
# Clean up the downloaded file
rm fastfetch-linux-amd64.deb
Test It
fastfetch
Auto-run on Login (Optional)
echo "fastfetch" >> ~/.bashrc
Security Hardening - Part 1: Firewall Setup
Why Firewall First?
Before we do anything else, we need to secure your VPS by only allowing necessary traffic.
Step 1: Install UFW (Uncomplicated Firewall)
UFW should already be installed, but let’s make sure:
apt install ufw -y
Step 2: Set Default Policies
# Deny all incoming traffic by default
ufw default deny incoming
# Allow all outgoing traffic
ufw default allow outgoing
Step 3: Allow SSH (CRITICAL - Don’t Skip!)
IMPORTANT: Do this BEFORE enabling the firewall, or you’ll lock yourself out!
ufw allow 22/tcp comment 'SSH'
Step 4: Allow HTTP and HTTPS
For your websites and services:
ufw allow 80/tcp comment 'HTTP'
ufw allow 443/tcp comment 'HTTPS'
Step 5: Allow WireGuard (for homeserver tunnel)
WireGuard typically uses UDP port 51820:
ufw allow 51820/udp comment 'WireGuard'
Step 6: Enable the Firewall
ufw enable
You’ll see:
Command may disrupt existing ssh connections. Proceed with operation (y|n)?
Type y and press Enter.
Step 7: Verify Firewall Status
ufw status verbose
Expected output:
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip
To Action From
-- ------ ----
22/tcp ALLOW IN Anywhere # SSH
80/tcp ALLOW IN Anywhere # HTTP
443/tcp ALLOW IN Anywhere # HTTPS
51820/udp ALLOW IN Anywhere # WireGuard
22/tcp (v6) ALLOW IN Anywhere (v6) # SSH
80/tcp (v6) ALLOW IN Anywhere (v6) # HTTP
443/tcp (v6) ALLOW IN Anywhere (v6) # HTTPS
51820/udp (v6) ALLOW IN Anywhere (v6) # WireGuard
Quick Reference: UFW Commands
# Check status
ufw status
# Add a rule
ufw allow PORT/PROTOCOL
# Delete a rule
ufw delete allow PORT/PROTOCOL
# Disable firewall (careful!)
ufw disable
# Reset all rules
ufw reset
Security Hardening - Part 2: SSH Configuration
Why Harden SSH?
SSH is the main entry point to your server. We’ll make it more secure by:
- Disabling root password login (key-only authentication)
- Disabling password authentication entirely
- Optional: Change SSH port (security through obscurity)
Step 1: Backup Original SSH Config
Always backup before editing:
cp /etc/ssh/sshd_config /etc/ssh/sshd_config.backup
Step 2: Edit SSH Configuration
nano /etc/ssh/sshd_config
Step 3: Find and Modify These Settings
Use Ctrl+W in nano to search for each setting:
Disable Root Password Login
Find:
#PermitRootLogin yes
Change to:
PermitRootLogin prohibit-password
This allows root login but ONLY with SSH keys (no passwords).
Disable Password Authentication
Find:
#PasswordAuthentication yes
Change to:
PasswordAuthentication no
Disable Empty Passwords
Find:
#PermitEmptyPasswords no
Change to:
PermitEmptyPasswords no
Enable Public Key Authentication (should already be enabled)
Find:
#PubkeyAuthentication yes
Make sure it says:
PubkeyAuthentication yes
Step 4: Optional - Change SSH Port
Note: Only do this if you want extra obscurity. It reduces automated attacks but adds complexity.
Find:
#Port 22
Change to a high port number (example):
Port 2222
If you change the port, update your firewall:
ufw allow 2222/tcp comment 'SSH'
ufw delete allow 22/tcp
Step 5: Save and Exit
In nano:
- Press
Ctrl+X - Press
Yto confirm - Press
Enterto save
Step 6: Test SSH Configuration
Before restarting SSH, test the config for errors:
sshd -t
If no output, the config is valid!
If there are errors, it will show them. Fix before continuing.
Step 7: Restart SSH Service
systemctl restart ssh
Check SSH Service Status
systemctl status ssh
You should see:
● ssh.service - OpenBSD Secure Shell server
Loaded: loaded (/lib/systemd/system/ssh.service; enabled)
Active: active (running)
If Service Failed to Start
Check for configuration errors:
journalctl -u ssh -n 50
This shows the last 50 log entries for SSH service.
Quick Reference: SSH Service Commands on Ubuntu
# Restart SSH
systemctl restart ssh
# Check status
systemctl status ssh
# Stop SSH
systemctl stop ssh
# Start SSH
systemctl start ssh
# Enable on boot (should already be enabled)
systemctl enable ssh
Now test your new SSH connection from a new terminal window before closing your current session!
ssh root@YOUR_SERVER_IP
Step 9: Update Local SSH Config (Optional)
If you changed the port, update your local ~/.ssh/config:
# On your LOCAL machine (not the VPS)
nano ~/.ssh/config
Add or update:
Host homelab-vps
HostName YOUR_SERVER_IP
User root
Port 2222
IdentityFile ~/.ssh/id_ed25519
Now you can just use:
ssh homelab-vps
Security Hardening - Part 3: Timezone & Fail2ban
Step 1: Set Timezone
Setting the correct timezone helps with logs and scheduled tasks.
Check Current Timezone
timedatectl
Set to Your Timezone (Belgium)
timedatectl set-timezone Europe/Brussels
Verify
timedatectl
Should show:
Time zone: Europe/Brussels (CET, +0100)
Step 2: Install Fail2ban
Fail2ban monitors logs and bans IPs that show malicious signs (repeated failed logins, etc.).
Install Fail2ban
apt install fail2ban -y
Create Local Configuration
cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
nano /etc/fail2ban/jail.local
Find and Modify These Settings
Search for [sshd] section (use Ctrl+W in nano):
[sshd]
enabled = true
port = ssh
# If you changed SSH port, update it here:
# port = 2222
logpath = %(sshd_log)s
maxretry = 3
bantime = 3600
findtime = 600
What this means:
maxretry = 3: Ban after 3 failed attemptsfindtime = 600: Within 10 minutesbantime = 3600: Ban for 1 hour (3600 seconds)
Save and Exit
Ctrl+X, Y, Enter
Enable and Start Fail2ban
systemctl enable fail2ban
systemctl start fail2ban
Check Fail2ban Status
systemctl status fail2ban
Should show active (running).
Check Banned IPs (if any)
fail2ban-client status sshd
Step 3: Create Non-Root User (Optional but Recommended)
It’s good practice to not use root for daily tasks.
Create New User
adduser myNewUser
Follow prompts (set password, can skip other fields by pressing Enter).
Add to Sudo Group
usermod -aG sudo myNewUser
Copy SSH Keys to New User
mkdir -p /home/myNewUser/.ssh
cp /root/.ssh/authorized_keys /home/myNewUser/.ssh/
chown -R myUser:myNewUser /home/myNewUser/.ssh
chmod 700 /home/myNewUser/.ssh
chmod 600 /home/myNewUser/.ssh/authorized_keys
Test New User Login
From a new terminal:
ssh myNewUser@YOUR_SERVER_IP
Use Sudo for Admin Commands
sudo apt update
Optional: Disable Root Login Entirely
If you want to force everyone to use the non-root user:
nano /etc/ssh/sshd_config
Change:
PermitRootLogin prohibit-password
To:
PermitRootLogin no
Then restart SSH:
systemctl restart ssh
Note: Only do this after confirming the new user works!
Installing Docker & Docker Compose
Why Docker?
Docker makes it easy to deploy and manage:
- NextCloud
- Your React websites
- Any other services
- Everything isolated and reproducible
Step 1: Remove Old Docker Versions (if any)
apt remove docker docker-engine docker.io containerd runc 2>/dev/null || true
Step 2: Install Prerequisites
apt install ca-certificates curl gnupg lsb-release -y
Step 3: Add Docker’s Official GPG Key
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
Step 4: Add Docker Repository
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
Step 5: Update Package Index
apt update
Step 6: Install Docker Engine & Docker Compose
apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y
Step 7: Verify Docker Installation
docker --version
Should show something like:
Docker version 24.0.x, build xxxxx
docker compose version
Should show:
Docker Compose version v2.x.x
Step 8: Test Docker
Run the hello-world container:
docker run hello-world
Expected output:
Hello from Docker!
This message shows that your installation appears to be working correctly.
Step 9: Enable Docker to Start on Boot
systemctl enable docker
systemctl enable containerd
Step 10: Check Docker Service Status
systemctl status docker
Should show active (running).
Optional: Add Non-Root User to Docker Group
If you created a non-root user (myUser), add them to docker group:
usermod -aG docker myUser
Now myUser can run docker commands without sudo.
Note: User needs to log out and back in for this to take effect.
Quick Docker Commands Reference
# List running containers
docker ps
# List all containers (including stopped)
docker ps -a
# List images
docker images
# Stop a container
docker stop CONTAINER_NAME
# Remove a container
docker rm CONTAINER_NAME
# View logs
docker logs CONTAINER_NAME
# Docker Compose commands
docker compose up -d # Start services
docker compose down # Stop services
docker compose logs -f # View logs
docker compose ps # List services
Directory Structure Setup
Let’s create a good directory structure for your services:
mkdir -p /opt/docker
cd /opt/docker
Create directories for each service:
mkdir -p nextcloud
mkdir -p react-sites
mkdir -p reverse-proxy
mkdir -p wireguard
mkdir -p backups
Check structure:
tree -L 1 /opt/docker
Should show:
/opt/docker
├── backups
├── nextcloud
├── react-sites
├── reverse-proxy
└── wireguard
Clean Pangolin Setup Guide
Prerequisites
- Ubuntu VPS with Docker installed
- Domain with DNS pointing to VPS IP
- Ports open: 22, 80, 443, 51820/udp
Step 1: Install Pangolin
cd /opt/docker
mkdir pangolin && cd pangolin
# Download installer
wget https://github.com/fosrl/pangolin/releases/latest/download/installer_linux_amd64
chmod +x installer_linux_amd64
# Run installer
./installer_linux_amd64
Follow prompts:
- Enter your domain (e.g.,
example.com) - Choose certificate resolver
- Wait for installation to complete
Step 2: Check Gerbil’s Subnet
cd /opt/docker/pangolin
docker compose logs gerbil | grep "Assigned IP"
Note the subnet (e.g., 100.89.128.1/24 means use 100.89.128.0/24)
Step 3: Initial Setup via Web UI
Visit https://pangolin.yourdomain.com
- Create admin account
- Email + password
- Enable 2FA (TOTP)
- Optional: Add YubiKey
- Create organization
- Name:
homelab(or your choice) - Subnet: Use Gerbil’s subnet from Step 2 (e.g.,
100.89.128.0/24)
- Name:
Step 4: Open Firewall Port
# Open WireGuard port for Newt/site connections
sudo ufw allow 51820/udp
sudo ufw reload
Step 5: Connect Docker Networks (for VPS services)
If you have existing Docker containers on a custom network:
# Connect Gerbil to your network
docker network connect YOUR_NETWORK_NAME gerbil
# Example:
docker network connect web gerbil
Step 6: Create TrueNAS Site
In Pangolin Dashboard:
- Sites → Add Site
- Fill in:
- Name:
tnt-truenas - Connection Type: Newt tunnel
- Address:
100.89.128.2(next IP after .1)
- Name:
- Create Site
- Copy Newt credentials (ID, Secret, Endpoint)
Step 7: Install Newt on TrueNAS
On TrueNAS (via Apps → Custom App):
services:
newt:
cap_add:
- NET_ADMIN
container_name: newt
environment:
- TZ=Europe/Brussels
- PANGOLIN_ENDPOINT=https://pangolin.mydomain.com:443
- NEWT_ID=[YOUR NEWT ID] # REPLACE THIS
- NEWT_SECRET=[YOUR NEWT SECRET] # REPLACE THIS
- ROUTE=192.168.50.0/24
extra_hosts:
- pangolin.mydomain.com:46.224.78.189
- pangolin:46.224.78.189
image: fosrl/newt:latest
network_mode: bridge
restart: unless-stopped
sysctls:
- net.ipv4.ip_forward=1
Resources: 1 CPU, 256 MB RAM
Step 8: Configure Remote Subnets
In Pangolin Dashboard:
- Sites → Click tnt-truenas
- General tab
- Remote Subnets: Add
192.168.0.0/24(your homelab network) - Save
Verify site shows “Online” (green)
Step 9: Expose Homelab Services
For each service (Jellyfin, Romm, etc.):
- Resources → Add Resource
- Fill in:
- Name:
Jellyfin - Subdomain:
jellyfin - Base Domain:
yourdomain.com
- Name:
- Add Target:
- Site:
tnt-truenas - Protocol:
http - IP:
192.168.50.203(TrueNAS IP) - Port:
8096(service port)
- Site:
- Authentication Tab:
- Use Platform SSO: ON (for 2FA protection)
- Create Resource
Access via: https://jellyfin.yourdomain.com
Truenas
Truenas Scale installation guide
SSH Container — Safe Remote Access & VS Code on TrueNAS
A safer alternative to enabling SSH directly on TrueNAS Scale, and a way to use VS Code as a full remote editor against your NAS data.
Overview
TrueNAS Scale’s system SSH daemon runs as root on the host OS, meaning any SSH access is privileged and directly touches the underlying system. Enabling it for day-to-day use is a security risk.
This guide sets up a dedicated Ubuntu SSH container that runs in Docker alongside your other stacks. It:
- Isolates SSH access to a container with a non-root user, keeping the TrueNAS host shell locked down
- Mounts only the data you need, rather than exposing the entire system
- Supports VS Code Remote-SSH, since TrueNAS’s musl-based shell can’t run the VS Code Server binary — the Ubuntu container provides a proper glibc environment
The result is a lightweight SSH gateway you can connect to from your laptop for file browsing, editing, and live access via VS Code, without touching the host.
Prerequisites
- TrueNAS Scale with Docker/Dockge running
- A private Docker registry (optional, but recommended for fast restarts)
- Your laptop’s SSH public key (
~/.ssh/id_rsa.pub)
Step 1: Build the Docker Image
1.1 Create the build directory
On TrueNAS, create a folder for the build files:
mkdir -p /mnt/your-pool/configs/docker-builds/data-ssh
cd /mnt/your-pool/configs/docker-builds/data-ssh
1.2 Create the Dockerfile
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update -q && \
apt-get install -y -q openssh-server && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* && \
mkdir -p /run/sshd && \
groupadd -g 568 truenas-data && \
useradd -m -u 1001 -s /bin/bash <your-username> && \
usermod -aG root <your-username> && \
usermod -aG 568 <your-username> && \
echo 'AllowTcpForwarding yes' >> /etc/ssh/sshd_config
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 22
CMD ["/entrypoint.sh"]
Note: Adjust the GID
568and UID1001to match your TrueNAS dataset ownership. Check withls -la /mnt/your-pool/to see the UIDs/GIDs in use.
1.3 Create the entrypoint script
#!/bin/bash
mkdir -p /home/<your-username>/.ssh
echo "$PUBLIC_KEY" > /home/<your-username>/.ssh/authorized_keys
chown -R <your-username>:<your-username> /home/<your-username>/.ssh
chmod 700 /home/<your-username>/.ssh
chmod 600 /home/<your-username>/.ssh/authorized_keys
exec /usr/sbin/sshd -D
Save this as entrypoint.sh in the same directory.
1.4 Build and push the image
sudo docker build -t <truenas-ip>:<registry-port>/data-ssh:latest .
sudo docker push <truenas-ip>:<registry-port>/data-ssh:latest
Replace <truenas-ip>:<registry-port> with your own registry address, or use data-ssh:latest
if you don’t have a private registry.
Step 2: Deploy with Dockge
2.1 Create the Compose file
In Dockge, create a new stack called data-ssh with the following:
services:
data-ssh:
image: <truenas-ip>:<registry-port>/data-ssh:latest
environment:
- PUBLIC_KEY=${SSH_PUBLIC_KEY}
volumes:
- ${DATA_PATH}:/data
ports:
- "2222:22"
restart: unless-stopped
2.2 Set environment variables
In Dockge’s env section (or a .env file):
SSH_PUBLIC_KEY=ssh-rsa AAAA...your-public-key-here...
DATA_PATH=/mnt/your-pool/data
Get your public key with:
cat ~/.ssh/id_rsa.pub
2.3 Start the stack
Deploy the stack in Dockge. The container should start in seconds since the image is pre-built.
Step 3: Configure SSH on Your Laptop
Add this to ~/.ssh/config:
Host truenas-data
HostName <truenas-ip>
Port 2222
User <your-username>
IdentityFile ~/.ssh/id_rsa
Test the connection:
ssh truenas-data
Accept the host key fingerprint when prompted.
Step 4: Connect with VS Code Remote-SSH
- Install the Remote - SSH extension in VS Code
- Open the Command Palette (
Cmd+Shift+P/Ctrl+Shift+P) - Run Remote-SSH: Connect to Host
- Select
truenas-data - VS Code will install its server inside the container and connect
- Use File > Open Folder and navigate to
/data
Troubleshooting
Connection refused
Check the container is running and sshd is listening:
sudo docker ps | grep data-ssh
sudo docker exec <container-name> ss -tlnp
Host key has changed error
Remove the stale key on your laptop:
ssh-keygen -R [<truenas-ip>]:2222
VS Code port forwarding disabled
Verify AllowTcpForwarding yes is in the container’s sshd_config:
sudo docker exec <container-name> grep AllowTcpForwarding /etc/ssh/sshd_config
Folders appear empty in VS Code
The VS Code server may have started before a group change took effect. Kill it to force a restart:
sudo docker exec <container-name> pkill -f vscode-server
Then reconnect in VS Code.
Permission denied on certain folders
Check folder ownership:
sudo docker exec <container-name> ls -la /data
If folders are owned by a GID not assigned to <your-username>, add the group:
sudo docker exec <container-name> groupadd -g <GID> extra-group
sudo docker exec <container-name> usermod -aG <GID> <your-username>
sudo docker exec <container-name> pkill -f vscode-server
Then rebuild the image with the new group baked in for persistence.
Rebuilding the Image
After any changes to the Dockerfile or entrypoint:
cd /mnt/your-pool/configs/docker-builds/data-ssh
sudo docker build -t <truenas-ip>:<registry-port>/data-ssh:latest .
sudo docker push <truenas-ip>:<registry-port>/data-ssh:latest
Then restart the stack in Dockge to pull the new image.
First Website Deployment
3.1 Create Website Directory
mkdir -p /opt/docker/websites/vps-test/html
cd /opt/docker/websites/vps-test
3.2 Create Test HTML Page
File: /opt/docker/websites/vps-test/html/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VPS Test Site</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container {
text-align: center;
}
h1 { font-size: 3em; margin: 0; }
p { font-size: 1.5em; }
.success { color: #4ade80; }
</style>
</head>
<body>
<div class="container">
<h1>🎉 Success!</h1>
<p>Traefik + SSL is working perfectly!</p>
<p class="success">✓ Automatic HTTPS</p>
<p class="success">✓ Reverse Proxy Active</p>
</div>
</body>
</html>
3.3 Create Website Docker Compose
File: /opt/docker/websites/vps-test/docker-compose.yml
version: '3.8'
services:
vps-test:
image: nginx:alpine
container_name: vps-test
restart: unless-stopped
networks:
- web
volumes:
- ./html:/usr/share/nginx/html:ro
labels:
- "traefik.enable=true"
- "traefik.http.routers.vpstest.rule=Host(`vps-test.mydomain.com`)"
- "traefik.http.routers.vpstest.entrypoints=websecure"
- "traefik.http.routers.vpstest.tls.certresolver=letsencrypt"
- "traefik.http.services.vpstest.loadbalancer.server.port=80"
networks:
web:
external: true
Label Breakdown:
traefik.enable=true: Tell Traefik to route this containerHost(\vps-test.mydomain.com`)`: Match this domainwebsecure: Use HTTPS (port 443)letsencrypt: Get SSL certificate automaticallyport=80: Nginx listens on port 80 internally
3.4 Deploy Test Site
cd /opt/docker/websites/vps-test
docker compose up -d
# Check logs
docker compose logs -f
3.5 Verify Website
- Open browser:
https://vps-test.mydomain.com - Should see:
- ✅ Valid SSL certificate (green padlock)
- ✅ No certificate warnings
- ✅ Test page displaying correctly
Website Deployment with GitHub Actions CI/CD
Overview
This phase covers deploying production websites to your VPS with automatic deployment via GitHub Actions. Once configured, you can simply push code to GitHub and your website automatically builds and deploys to your VPS.
Example Website: Boeygembos
Details:
- Domain: www.boeygembos.be
- Tech: React (Vite)
- Build:
npm run build→dist/ - Repo: https://github.com/boeygembos/boeygembos.github.io
- Subfolder: Code in
boeygembos-app/subdirectory
Architecture
File Structure on VPS
/opt/docker/websites/boeygembos/
├── docker-compose.yml # Container definition + Traefik labels
├── nginx.conf # Nginx config for React SPA routing
└── html/ # Website files (deployed here)
├── index.html
├── assets/
└── ...
Part 1: Prepare DNS Records
Step 1.3: Get Your VPS IP Addresses
IPv4 (required):
# On your VPS
curl -4 ifconfig.me
IPv6 (optional):
# Check if you have IPv6
curl -6 ifconfig.me
Or check Hetzner Cloud Console - it shows both IPs on your server overview.
Note about IPv6:
- If your VPS IP ends with
::/64, you have a subnet but no single assigned address - For simplicity, you can skip IPv6 and delete AAAA records
- IPv4 is sufficient for 99.9% of users
Step 1.4: Update DNS Records
Change these records in Domain.com:
Record 1: Root domain
Type: A
Host: @ (or root domain)
Points To: YOUR_VPS_IPv4
TTL: 3600 (1 hour)
Record 2: www subdomain
Type: A
Host: www (or "Other Host" → www)
Points To: YOUR_VPS_IPv4
TTL: 3600 (1 hour)
IPv6 (optional):
- If you have IPv6: Update AAAA records to your VPS IPv6 (without the /64)
- If you don’t have IPv6: Delete both AAAA records
Step 1.5: Wait for DNS Propagation
DNS changes take time to propagate:
- Typical: 2-10 minutes
- Maximum: Up to the old TTL value (could be hours)
Verify DNS:
# Check if DNS resolves to your VPS IP
dig yourdomain.com +short
dig www.yourdomain.com +short
# Should show your VPS IP
Or use online tools:
- https://dnschecker.org/
- https://www.whatsmydns.net/
Part 2: Deploy Website to VPS
Step 2.1: Create Website Directory
SSH into your VPS:
# Create directory for your website
mkdir -p /opt/docker/websites/YOUR_SITE_NAME
cd /opt/docker/websites/YOUR_SITE_NAME
Replace YOUR_SITE_NAME with something descriptive (e.g., boeygembos, client-site-1).
Step 2.2: Get Built Files
# On your local machine:
git clone YOUR_GITHUB_REPO_URL
cd YOUR_REPO
cd SUBDIRECTORY_IF_ANY
npm install
npm run build
# Upload to VPS (replace with your details)
scp -r dist/* root@YOUR_VPS_IP:/opt/docker/websites/YOUR_SITE_NAME/html/
Step 2.3: Create Nginx Configuration
For React/Vue/Angular SPAs (single-page applications), you need special routing:
nano /opt/docker/websites/YOUR_SITE_NAME/nginx.conf
Paste this configuration:
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Enable gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# React/Vue/Angular Router - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
}
For plain HTML sites (no client-side routing), you can use a simpler config or skip this file entirely.
Save with Ctrl+X, Y, Enter.
Step 2.4: Create Docker Compose File
nano /opt/docker/websites/YOUR_SITE_NAME/docker-compose.yml
Basic configuration (single domain):
version: '3.8'
services:
YOUR_SITE_NAME:
image: nginx:alpine
container_name: YOUR_SITE_NAME
restart: unless-stopped
networks:
- web
volumes:
- ./html:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
labels:
- "traefik.enable=true"
- "traefik.http.routers.YOUR_SITE_NAME.rule=Host(`www.yourdomain.com`)"
- "traefik.http.routers.YOUR_SITE_NAME.entrypoints=websecure"
- "traefik.http.routers.YOUR_SITE_NAME.tls.certresolver=letsencrypt"
- "traefik.http.services.YOUR_SITE_NAME.loadbalancer.server.port=80"
networks:
web:
external: true
Advanced: With www redirect (redirects non-www to www):
version: '3.8'
services:
YOUR_SITE_NAME:
image: nginx:alpine
container_name: YOUR_SITE_NAME
restart: unless-stopped
networks:
- web
volumes:
- ./html:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
labels:
- "traefik.enable=true"
# Main domain: www.yourdomain.com
- "traefik.http.routers.YOUR_SITE_NAME.rule=Host(`www.yourdomain.com`)"
- "traefik.http.routers.YOUR_SITE_NAME.entrypoints=websecure"
- "traefik.http.routers.YOUR_SITE_NAME.tls.certresolver=letsencrypt"
# Redirect non-www to www
- "traefik.http.routers.YOUR_SITE_NAME-redirect.rule=Host(`yourdomain.com`)"
- "traefik.http.routers.YOUR_SITE_NAME-redirect.entrypoints=websecure"
- "traefik.http.routers.YOUR_SITE_NAME-redirect.tls.certresolver=letsencrypt"
- "traefik.http.routers.YOUR_SITE_NAME-redirect.middlewares=redirect-to-www"
- "traefik.http.middlewares.redirect-to-www.redirectregex.regex=^https://yourdomain.com/(.*)"
- "traefik.http.middlewares.redirect-to-www.redirectregex.replacement=https://www.yourdomain.com/$${1}"
- "traefik.http.middlewares.redirect-to-www.redirectregex.permanent=true"
- "traefik.http.services.YOUR_SITE_NAME.loadbalancer.server.port=80"
networks:
web:
external: true
Important replacements:
YOUR_SITE_NAME→ Your site name (lowercase, no spaces)yourdomain.com→ Your actual domainwww.yourdomain.com→ Your www subdomain
Save with Ctrl+X, Y, Enter.
Step 2.5: Deploy the Website
cd /opt/docker/websites/YOUR_SITE_NAME
# Verify files are in place
ls -la html/
# Should see index.html and other files
# Start the container
docker compose up -d
# Check logs
docker compose logs -f
Watch for Traefik requesting SSL certificates. Press Ctrl+C when done.
Step 2.6: Verify Website Works
- Open browser:
https://www.yourdomain.com - Should see:
- ✅ Valid SSL certificate (green padlock)
- ✅ Your website loading
- ✅ No certificate warnings
- Test redirect (if configured):
https://yourdomain.com→https://www.yourdomain.com - Check Traefik dashboard:
https://traefik.mydomain.com- See your router in HTTP Routers
- See SSL certificate in TLS section
Part 3: Setup GitHub Actions CI/CD
Now let’s automate deployments so you can just push to GitHub!
Step 3.1: Create SSH Key for GitHub Actions
On your VPS:
# Create dedicated SSH key for deployments
ssh-keygen -t ed25519 -C "github-actions-YOUR_SITE" -f ~/.ssh/github-actions-YOUR_SITE -N ""
# Add public key to authorized_keys
cat ~/.ssh/github-actions-YOUR_SITE.pub >> ~/.ssh/authorized_keys
# Display private key (you'll copy this)
cat ~/.ssh/github-actions-YOUR_SITE
Copy the entire private key output, including:
-----BEGIN OPENSSH PRIVATE KEY------ All the encoded content
-----END OPENSSH PRIVATE KEY-----
Why Ed25519?
- Modern, secure cryptography
- Smaller keys than RSA
- Faster signing/verification
- Widely supported
Alternative with RSA:
ssh-keygen -t rsa -b 4096 -C "github-actions-YOUR_SITE" -f ~/.ssh/github-actions-YOUR_SITE -N ""
Step 3.2: Add GitHub Secrets
Go to your GitHub repository settings:
URL format:
https://github.com/USERNAME/REPO_NAME/settings/secrets/actions
Click New repository secret and add these three secrets:
Secret 1: SSH Private Key
- Name:
VPS_SSH_KEY - Value: Paste the entire private key (from Step 3.1)
- Click Add secret
Secret 2: VPS IP Address
- Name:
VPS_HOST - Value: Your VPS IP address (e.g.,
123.45.67.89) - Click Add secret
Secret 3: SSH User
- Name:
VPS_USER - Value:
root - Click Add secret
You should now see three secrets listed (values are hidden for security).
Step 3.3: Create GitHub Actions Workflow
On your local machine, in your repository:
# Navigate to your repo
cd /path/to/your-repo
# Create workflows directory
mkdir -p .github/workflows
# Create workflow file
nano .github/workflows/deploy.yml
Basic workflow (code in repo root):
name: Build and Deploy to VPS
on:
push:
branches:
- main # Change to your branch name (main, master, release, etc.)
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Build
run: npm run build
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H ${{ secrets.VPS_HOST }} >> ~/.ssh/known_hosts
- name: Deploy to VPS
run: |
rsync -avz --delete \
-e "ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no" \
dist/ \
${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/opt/docker/websites/YOUR_SITE_NAME/html/
- name: Restart container
run: |
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \
"cd /opt/docker/websites/YOUR_SITE_NAME && docker compose restart"
- name: Cleanup
if: always()
run: rm -f ~/.ssh/deploy_key
Advanced workflow (code in subdirectory):
If your code is in a subdirectory (like boeygembos-app/):
name: Build and Deploy to VPS
on:
push:
branches:
- release # Your branch name
jobs:
build-and-deploy:
runs-on: ubuntu-latest
# Set default working directory
defaults:
run:
working-directory: YOUR_SUBDIRECTORY_NAME
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: 'YOUR_SUBDIRECTORY_NAME/package-lock.json'
- name: Install dependencies
run: npm install
- name: Build
run: npm run build
- name: Setup SSH
working-directory: ${{ github.workspace }}
run: |
mkdir -p ~/.ssh
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H ${{ secrets.VPS_HOST }} >> ~/.ssh/known_hosts
- name: Deploy to VPS
run: |
rsync -avz --delete \
-e "ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no" \
dist/ \
${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/opt/docker/websites/YOUR_SITE_NAME/html/
- name: Restart container
working-directory: ${{ github.workspace }}
run: |
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \
"cd /opt/docker/websites/YOUR_SITE_NAME && docker compose restart"
- name: Cleanup
if: always()
working-directory: ${{ github.workspace }}
run: rm -f ~/.ssh/deploy_key
Important customizations:
- Line 5: Change branch name (
main,master,release, etc.) - Line 11 (advanced): Change
YOUR_SUBDIRECTORY_NAMEto your subdirectory - Line 17 (advanced): Update cache path to match your subdirectory
- Line 38 & 45: Change
dist/if your build outputs to a different directory (build/,out/, etc.) - Line 39 & 46: Change
/opt/docker/websites/YOUR_SITE_NAME/to your actual path
Step 3.4: Commit and Push Workflow
# Add the workflow file
git add .github/workflows/deploy.yml
# Commit
git commit -m "Add GitHub Actions auto-deployment workflow"
# Push (use your branch name)
git push origin main # or master, release, etc.
Step 3.5: Watch First Deployment
- Go to your GitHub repository
- Click Actions tab (top menu)
- You should see a workflow running: “Build and Deploy to VPS”
- Click on it to see live progress
Workflow steps:
- ✅ Checkout code (~5 seconds)
- ✅ Setup Node.js (~10 seconds)
- ✅ Install dependencies (~30-60 seconds)
- ✅ Build (~10-30 seconds)
- ✅ Setup SSH (~2 seconds)
- ✅ Deploy to VPS (~5-15 seconds depending on file size)
- ✅ Restart container (~2 seconds)
- ✅ Cleanup (~1 second)
Total time: ~1-2 minutes
Step 3.6: Verify Auto-Deployment
Once the workflow completes successfully:
- Check your website:
https://www.yourdomain.com - Should show the latest version
Part 4: Test the Full Workflow
Make a small change to test automatic deployment:
# In your local repo
# Make any small change (edit a title, add a comment, etc.)
# Stage changes
git add .
# Commit
git commit -m "Test auto-deployment workflow"
# Push to trigger deployment
git push origin YOUR_BRANCH_NAME
What happens:
- Push triggers GitHub Actions
- GitHub builds your site
- Deploys to VPS automatically
- Site updates in ~1-2 minutes
- No manual steps needed!
Watch it in GitHub Actions tab - you should see a new workflow run automatically.
Troubleshooting
Common Issues and Solutions
Issue 1: DNS Not Resolving
Symptoms:
- Can’t access website
digshows old IP or no result
Solutions:
# Check DNS resolution
dig yourdomain.com +short
dig www.yourdomain.com +short
# Should show your VPS IP
# If not:
# 1. Wait longer (up to old TTL)
# 2. Check DNS records in registrar
# 3. Flush local DNS cache:
# Linux: sudo systemd-resolve --flush-caches
# macOS: sudo dscacheutil -flushcache
Issue 2: Certificate Not Generating
Symptoms:
- Browser shows certificate error
- “Unable to obtain certificate” in logs
Solutions:
# 1. Verify DNS points to VPS
dig yourdomain.com +short
# 2. Check Traefik logs
cd /opt/docker/traefik
docker compose logs | grep -i acme
# 3. Ensure port 80 is accessible (needed for HTTP challenge)
curl -I http://yourdomain.com
# 4. Check Let's Encrypt rate limits
# https://letsencrypt.org/docs/rate-limits/
# 5. Restart Traefik if needed
docker compose restart
Issue 3: GitHub Actions - Dependencies Lock File Not Found
Error message:
Dependencies lock file is not found in /home/runner/work/...
Supported file patterns: package-lock.json, npm-shrinkwrap.json, yarn.lock
Solution 1: Commit lock file (recommended)
# Create lock file if it doesn't exist
npm install
# Add to git
git add package-lock.json
git commit -m "Add package-lock.json for CI/CD"
git push
Solution 2: Use npm install instead of npm ci
In .github/workflows/deploy.yml, change:
- name: Install dependencies
run: npm ci # Change this...
To:
- name: Install dependencies
run: npm install # ...to this
Issue 4: GitHub Actions - VPS_HOST Secret Empty
Error message:
usage: ssh-keyscan [-46cDHv] [-f file] [-O option] [-p port] [-T timeout]
Error: Process completed with exit code 1
Solution:
The secret is missing or empty. Add it:
- Go to GitHub:
Settings → Secrets and variables → Actions - Check if
VPS_HOSTexists - If missing: New repository secret
- Name:
VPS_HOST - Value: Your VPS IP (e.g.,
123.45.67.89)
- Name:
- If exists but empty: Edit and update value
Same for VPS_USER - should be root.
Issue 5: GitHub Actions - dist/ Folder Not Found
Error message:
rsync: change_dir "/home/runner/.../dist" failed: No such file or directory
Possible causes:
Cause 1: Build output is different directory
Check your build output:
# Locally
npm run build
ls -la
# Check what directory was created
# Could be: build/, dist/, out/, .output/, etc.
In deploy.yml, update the rsync path:
- name: Deploy to VPS
run: |
rsync -avz --delete \
-e "ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no" \
build/ \ # Change 'dist/' to your actual output directory
${{ secrets.VPS_USER }}@...
Cause 2: Build failed silently
Add debug steps after build:
- name: Build
run: npm run build
- name: Check build output
run: |
echo "Build completed"
ls -la
[ -d "dist" ] && echo "dist exists" || echo "dist NOT found"
Cause 3: Working directory issue (subdirectory projects)
If your code is in a subdirectory, ensure you’re using defaults.run.working-directory correctly. See the advanced workflow example above.
Issue 6: Container Won’t Start
Check container logs:
cd /opt/docker/websites/YOUR_SITE_NAME
docker compose logs
# Common issues:
# - Port conflict
# - Volume mount path wrong
# - nginx.conf syntax error
Test nginx config:
# Test config syntax
docker run --rm -v $(pwd)/nginx.conf:/etc/nginx/conf.d/default.conf:ro nginx:alpine nginx -t
Issue 7: Website Shows Old Content
After deployment, site doesn’t update:
Solution 1: Clear browser cache
- Hard refresh:
Ctrl+Shift+R(Linux/Windows) orCmd+Shift+R(Mac)
Solution 2: Verify files were deployed
# On VPS
ls -la /opt/docker/websites/YOUR_SITE_NAME/html/
# Check file timestamps - should be recent
Solution 3: Force container recreation
cd /opt/docker/websites/YOUR_SITE_NAME
docker compose down
docker compose up -d --force-recreate
Issue 8: React Router Not Working (404 on refresh)
Symptoms:
- Homepage works
- Navigation works
- Refreshing on a route gives 404
Solution:
Ensure you have the SPA nginx config (Step 2.3) with:
location / {
try_files $uri $uri/ /index.html;
}
This serves index.html for all routes, letting React Router handle routing.
Issue 9: GitHub Actions - Permission Denied (SSH)
Error message:
Permission denied (publickey)
Solutions:
- Verify SSH key in VPS authorized_keys:
# On VPS
cat ~/.ssh/authorized_keys | grep github-actions
# Should see your key
- Regenerate and re-add key:
# On VPS - create new key
ssh-keygen -t ed25519 -C "github-actions-site" -f ~/.ssh/github-actions-site -N ""
cat ~/.ssh/github-actions-site.pub >> ~/.ssh/authorized_keys
# Display private key
cat ~/.ssh/github-actions-site
# Copy and update VPS_SSH_KEY secret in GitHub
- Test SSH connection locally:
# Save private key to file temporarily
nano test_key
# Paste private key
chmod 600 test_key
# Test connection
ssh -i test_key root@YOUR_VPS_IP "echo 'SSH works!'"
# Remove test file
rm test_key
Decap CMS OAuth Authentication - Single Site Setup
Step-by-Step Setup
Part 1: Create GitHub OAuth Application
Step 1.1: Navigate to GitHub Settings
- Log into GitHub
- Click your profile picture (top right)
- Go to Settings
- Scroll down in left sidebar
- Click Developer settings (near bottom)
- Click OAuth Apps
- Click New OAuth App button
Step 1.2: Fill Out OAuth App Form
You’ll see a form with several fields:
Application name:
My Site CMS
(Replace “My Site” with your actual site name, e.g., “Boeygembos CMS”)
Homepage URL:
https://www.mysite.com
(Your actual website URL - must include https://)
Application description: (Optional)
OAuth authentication for Decap CMS content management
Authorization callback URL:
https://oauth.mysite.com/callback
Important notes about the callback URL:
- This is where GitHub redirects after authentication
- Must be
https://(nothttp://) /callbackpath is important - don’t change it- Can use subdomain of your site OR your VPS domain
- Examples:
https://oauth.boeygembos.be/callback✅https://oauth.mysite.com/callback✅https://mysite.com/oauth/callback✅
Which domain to use for OAuth?
- Same domain as site:
oauth.mysite.com(cleaner, recommended) - VPS domain:
oauth.yourvps.com(works for multiple sites) - Either works - just be consistent!
Step 1.3: Register and Save Credentials
- Click Register application
- You’ll see the app overview page
Copy the Client ID:
- Displayed on screen (looks like:
Iv1.abcdef123456) - Save this somewhere secure
Generate Client Secret:
- Click Generate a new client secret button
- IMPORTANT: Copy the secret immediately!
- It’s only shown once - you can’t see it again
- Save it securely (looks like:
abc123def456...)
Keep these safe:
Client ID: Iv1.abcdef123456
Client Secret: abc123def456ghi789jkl012mno345pqr678
You’ll need both in the next steps.
Part 2: Prepare DNS
Step 2.1: Choose OAuth Domain
Decide where your OAuth provider will live:
Option A: Subdomain of your site (Recommended)
oauth.mysite.com
- Clean and organized
- Each site gets its own OAuth subdomain
Option B: Shared OAuth domain
oauth.yourvps.com
- Can be reused for multiple sites
- Slightly more complex setup later
For this guide, we’ll use: oauth.mysite.com
Step 2.2: Add DNS Record
Log into your DNS provider (Domain.com, Cloudflare, etc.)
Add A Record:
Type: A
Name: oauth (or "oauth.mysite.com" depending on interface)
Value: YOUR_VPS_IP_ADDRESS
TTL: 3600 (1 hour)
Example for Domain.com:
- Login → Domain Management
- Click your domain → DNS Records
- Add Advanced DNS Record
- Type:
A - Refers to: Select “Other Host” → type
oauth - Points to: Your VPS IP (e.g.,
123.45.67.89) - TTL: 3600
- Save
Step 2.3: Verify DNS
Wait 2-5 minutes, then verify:
# From your computer or VPS
dig oauth.mysite.com +short
Should return your VPS IP address.
Or use online tool: https://dnschecker.org/
Part 3: Deploy OAuth Provider on VPS
Step 3.1: Create Directory Structure
SSH into your VPS:
ssh root@YOUR_VPS_IP
Create directory:
mkdir -p /opt/docker/decap-oauth
cd /opt/docker/decap-oauth
Step 3.2: Create Node.js Server
Create the OAuth server file:
nano server.js
Paste this code:
const express = require('express');
const cors = require('cors');
const { AuthorizationCode } = require('simple-oauth2');
const app = express();
const port = process.env.PORT || 3000;
// CORS configuration
app.use(cors({
origin: true,
credentials: true
}));
// OAuth2 configuration
const client = new AuthorizationCode({
client: {
id: process.env.OAUTH_CLIENT_ID,
secret: process.env.OAUTH_CLIENT_SECRET,
},
auth: {
tokenHost: 'https://github.com',
tokenPath: '/login/oauth/access_token',
authorizePath: '/login/oauth/authorize',
},
});
// Redirect to GitHub OAuth
app.get('/auth', (req, res) => {
const authorizationUri = client.authorizeURL({
redirect_uri: `${process.env.BASE_URL}/callback`,
scope: 'repo,user',
state: req.query.state || '',
});
console.log('Auth request received, redirecting to GitHub');
res.redirect(authorizationUri);
});
// OAuth callback
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
try {
const tokenParams = {
code,
redirect_uri: `${process.env.BASE_URL}/callback`,
};
const accessToken = await client.getToken(tokenParams);
const token = accessToken.token.access_token;
// Get the origin from environment
const redirectOrigin = process.env.REDIRECT_URL;
console.log('Authentication successful, sending token to CMS');
// Send token back to CMS via postMessage
const script = `
<!DOCTYPE html>
<html>
<head>
<title>Authentication Successful</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.message {
text-align: center;
padding: 2rem;
background: rgba(255,255,255,0.1);
border-radius: 10px;
backdrop-filter: blur(10px);
}
.spinner {
border: 3px solid rgba(255,255,255,0.3);
border-top: 3px solid white;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 20px auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="message">
<h1>✓ Authentication Successful</h1>
<div class="spinner"></div>
<p>Redirecting back to CMS...</p>
</div>
<script>
(function() {
function receiveMessage(e) {
console.log("receiveMessage %o", e);
window.opener.postMessage(
'authorization:github:success:${JSON.stringify({ token, provider: 'github' })}',
e.origin
);
}
window.addEventListener("message", receiveMessage, false);
window.opener.postMessage("authorizing:github", "${redirectOrigin}");
// Auto-close after 2 seconds
setTimeout(function() {
window.close();
}, 2000);
})();
</script>
</body>
</html>
`;
res.send(script);
} catch (error) {
console.error('Authentication failed:', error.message);
res.status(500).send(`
<!DOCTYPE html>
<html>
<head>
<title>Authentication Failed</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.message {
text-align: center;
padding: 2rem;
background: rgba(255,255,255,0.1);
border-radius: 10px;
backdrop-filter: blur(10px);
}
.error {
background: rgba(0,0,0,0.2);
padding: 1rem;
border-radius: 5px;
margin-top: 1rem;
font-family: monospace;
font-size: 0.9em;
}
</style>
</head>
<body>
<div class="message">
<h1>✗ Authentication Failed</h1>
<p>There was an error during authentication.</p>
<div class="error">${error.message}</div>
<p style="margin-top: 1rem; font-size: 0.9em;">
You can close this window and try again.
</p>
</div>
</body>
</html>
`);
}
});
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
message: 'OAuth provider is running',
uptime: process.uptime()
});
});
// Root endpoint
app.get('/', (req, res) => {
res.json({
message: 'OAuth Provider for Decap CMS',
endpoints: {
auth: '/auth',
callback: '/callback',
health: '/health'
},
configured_for: process.env.REDIRECT_URL
});
});
app.listen(port, () => {
console.log('='.repeat(50));
console.log('OAuth Provider for Decap CMS');
console.log('='.repeat(50));
console.log(`Server listening on port ${port}`);
console.log(`Base URL: ${process.env.BASE_URL}`);
console.log(`Redirect URL: ${process.env.REDIRECT_URL}`);
console.log('='.repeat(50));
});
Save with Ctrl+X, then Y, then Enter.
What this does:
/auth- Redirects user to GitHub for authentication/callback- Receives code from GitHub, exchanges for token/health- Health check endpoint/- Shows configuration info
Step 3.3: Create package.json
nano package.json
Paste:
{
"name": "decap-oauth-provider",
"version": "1.0.0",
"description": "OAuth provider for Decap CMS",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"simple-oauth2": "^5.0.0"
},
"engines": {
"node": ">=18.0.0"
}
}
Save with Ctrl+X, Y, Enter.
Step 3.4: Create Dockerfile
nano Dockerfile
Paste:
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package.json ./
# Install dependencies
RUN npm install --production
# Copy application
COPY server.js ./
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Start application
CMD ["node", "server.js"]
Save with Ctrl+X, Y, Enter.
Step 3.5: Create docker-compose.yml
nano docker-compose.yml
Paste (replace the placeholder values):
version: '3.8'
services:
decap-oauth:
build: .
container_name: decap-oauth
restart: unless-stopped
networks:
- web
environment:
# Server configuration
- NODE_ENV=production
- PORT=3000
# OAuth configuration - REPLACE THESE VALUES!
- OAUTH_CLIENT_ID=YOUR_GITHUB_CLIENT_ID_HERE
- OAUTH_CLIENT_SECRET=YOUR_GITHUB_CLIENT_SECRET_HERE
- BASE_URL=https://oauth.mysite.com
- REDIRECT_URL=https://www.mysite.com
labels:
# Enable Traefik
- "traefik.enable=true"
# Router configuration
- "traefik.http.routers.decap-oauth.rule=Host(`oauth.mysite.com`)"
- "traefik.http.routers.decap-oauth.entrypoints=websecure"
- "traefik.http.routers.decap-oauth.tls.certresolver=letsencrypt"
# Service configuration
- "traefik.http.services.decap-oauth.loadbalancer.server.port=3000"
networks:
web:
external: true
IMPORTANT - Replace these values:
- OAUTH_CLIENT_ID: Your GitHub Client ID from Part 1
- OAUTH_CLIENT_SECRET: Your GitHub Client Secret from Part 1
- BASE_URL: Your OAuth provider URL (e.g.,
https://oauth.mysite.com) - REDIRECT_URL: Your website URL (e.g.,
https://www.mysite.com) - Host in labels: Change
oauth.mysite.comto your actual OAuth domain
Example with real values:
- OAUTH_CLIENT_ID=Iv1.abc123def456
- OAUTH_CLIENT_SECRET=abc123def456ghi789jkl012
- BASE_URL=https://oauth.boeygembos.be
- REDIRECT_URL=https://www.boeygembos.be
Save with Ctrl+X, Y, Enter.
Step 3.6: Build and Start Container
# Build the Docker image
docker compose build
# Start the container
docker compose up -d
# Check if it's running
docker ps | grep decap-oauth
Should show container running.
Step 3.7: Check Logs
docker compose logs
Should see something like:
==================================================
OAuth Provider for Decap CMS
==================================================
Server listening on port 3000
Base URL: https://oauth.mysite.com
Redirect URL: https://www.mysite.com
==================================================
If you see errors, check Troubleshooting section.
Step 3.8: Test OAuth Provider
# Health check
curl https://oauth.mysite.com/health
# Should return:
# {"status":"healthy","message":"OAuth provider is running","uptime":123.45}
# Check configuration
curl https://oauth.mysite.com/
# Should return info about endpoints
If you get errors or 404:
- Wait 1-2 minutes for SSL certificate generation
- Check Traefik dashboard:
https://traefik.yourvps.com - Verify DNS is resolving:
dig oauth.mysite.com +short
Part 4: Configure Decap CMS
Step 4.1: Locate Decap CMS Config
In your website repository, find the Decap CMS configuration file.
Common locations:
public/admin/config.yml
static/admin/config.yml
admin/config.yml
src/admin/config.yml
Find it:
# In your local project
find . -name "config.yml" -path "*/admin/*"
Step 4.2: Update Backend Configuration
Edit your config.yml file:
Before (local development only):
backend:
name: github
repo: username/repository
branch: main
After (works in production):
backend:
name: github
repo: username/repository
branch: main
base_url: https://oauth.mysite.com
auth_endpoint: auth
Important:
- Replace
username/repositorywith your actual GitHub repo - Replace
https://oauth.mysite.comwith your actual OAuth provider URL auth_endpoint: authpoints to the/authroute in your OAuth provider- Keep
branch:set to whatever branch you use
Full example:
backend:
name: github
repo: boeygembos/boeygembos.github.io
branch: release
base_url: https://oauth.boeygembos.be
auth_endpoint: auth
media_folder: "public/images/uploads"
public_folder: "/images/uploads"
collections:
- name: "pages"
label: "Pages"
files:
- label: "Home"
name: "home"
file: "content/home.md"
fields:
- { label: "Title", name: "title", widget: "string" }
- { label: "Body", name: "body", widget: "markdown" }
Step 4.3: Commit and Deploy
# In your local project
git add public/admin/config.yml # or wherever your config is
git commit -m "Add OAuth authentication for production CMS"
git push origin main # or your branch name
If you have GitHub Actions CI/CD set up, wait for deployment (~2 minutes).
If deploying manually:
# Build locally
npm run build
# Upload to VPS (adjust paths)
scp -r dist/* root@YOUR_VPS_IP:/opt/docker/websites/yoursite/html/
# Or rebuild on VPS if that's your workflow
Testing
Test 1: Access CMS Admin
- Open your browser
- Go to:
https://www.mysite.com/admin/ - You should see the Decap CMS interface
- Look for “Login with GitHub” button
If you don’t see the admin page:
- Check if
/admin/directory exists in your deployed files - Verify Decap CMS is properly configured in your project
- Check browser console (F12) for errors
Test 2: Authenticate with GitHub
- Click “Login with GitHub” button
- Should redirect to
oauth.mysite.com/auth - Then redirects to GitHub
- You’ll see GitHub authorization page:
- “Authorize [Your App Name]”
- Shows permissions requested (repo, user)
- Click “Authorize [username]”
- Should redirect back to your CMS
- You should now be logged in! ✅
What you should see:
- CMS loads your content
- You can browse collections
- You can edit entries
- “Publish” or “Save” buttons are available
Test 3: Edit and Save Content
- In the CMS, click on a collection (e.g., “Pages”)
- Click on an entry or create a new one
- Make a change (edit text, add content)
- Click “Publish” or “Save”
- Should see success message
- Check GitHub repository - should see a new commit!
Verify the commit:
- Go to your GitHub repo
- Check recent commits
- Should see a commit from your GitHub account
- Commit message will be from Decap CMS
Test 4: Verify Changes on Live Site
- Wait a few minutes (if using CI/CD)
- Visit your live website
- Changes should be visible
- Content you edited should be updated
If changes don’t appear:
- Check if CI/CD workflow ran successfully
- Verify deployment completed
- Clear browser cache and refresh
- Check if you edited the correct branch
Test 5: OAuth Provider Health
# From your computer or VPS
# Health check
curl https://oauth.mysite.com/health
# Should return:
{
"status": "healthy",
"message": "OAuth provider is running",
"uptime": 12345.67
}
# Check container logs
ssh root@YOUR_VPS_IP
cd /opt/docker/decap-oauth
docker compose logs --tail=50
Look for log entries when you authenticated:
Auth request received, redirecting to GitHub
Authentication successful, sending token to CMS
Troubleshooting
Issue 1: Can’t Access /admin/ Page
Symptoms:
https://www.mysite.com/admin/shows 404- CMS interface doesn’t load
Solutions:
- Check if admin files exist:
# On VPS
ls -la /opt/docker/websites/yoursite/html/admin/
Should see index.html and config.yml.
- Verify build includes admin files:
# Locally, after building
ls -la dist/admin/ # or build/admin/
- Check nginx configuration (if using SPA routing):
Make sure your nginx config serves static files correctly:
location /admin/ {
try_files $uri $uri/ /admin/index.html;
}
Issue 2: “Failed to Load Config” Error
Symptoms:
- Admin page loads but shows error
- “Error loading config.yml”
- Blank page with errors in console
Solutions:
- Check config.yml syntax:
# Validate YAML
python3 -c "import yaml; yaml.safe_load(open('public/admin/config.yml'))"
- Verify config.yml is deployed:
# On VPS
cat /opt/docker/websites/yoursite/html/admin/config.yml
- Check browser console (F12 → Console tab):
- Look for specific error messages
- YAML parsing errors
- 404 errors loading config
- Common config errors:
- Incorrect indentation (YAML is indent-sensitive)
- Missing required fields
- Incorrect backend configuration
Issue 3: OAuth Provider Returns 404
Symptoms:
- Clicking “Login with GitHub” redirects to OAuth provider
- Shows “404 Not Found” or “Cannot GET /auth”
Solutions:
- Check DNS resolves:
dig oauth.mysite.com +short
# Should return your VPS IP
- Check container is running:
ssh root@YOUR_VPS_IP
docker ps | grep decap-oauth
- Check container logs:
cd /opt/docker/decap-oauth
docker compose logs
Look for errors starting up.
- Test directly:
curl https://oauth.mysite.com/health
If this works, OAuth provider is running. Issue is with routing.
- Check Traefik configuration:
- Visit:
https://traefik.yourvps.com - Look for
decap-oauthin HTTP Routers - Verify route is configured correctly
- Verify SSL certificate:
curl -I https://oauth.mysite.com/health
Should return 200 OK with valid SSL.
Issue 4: GitHub Authorization Fails
Symptoms:
- Redirects to GitHub successfully
- After authorizing, get error
- “Bad credentials” or “Invalid client”
Solutions:
- Verify Client ID and Secret:
Check docker-compose.yml has correct values:
cd /opt/docker/decap-oauth
cat docker-compose.yml | grep OAUTH_CLIENT
- Regenerate Client Secret:
If you think secret is wrong:
- Go to: https://github.com/settings/developers
- Find your OAuth App
- Click “Generate a new client secret”
- Update docker-compose.yml with new secret
- Restart container:
docker compose restart
- Check callback URL matches:
In GitHub OAuth App settings:
Authorization callback URL: https://oauth.mysite.com/callback
Must EXACTLY match what’s in your config.
- Check container logs:
docker compose logs
Look for specific error messages from GitHub.
Issue 5: CORS Errors
Symptoms:
- Browser console shows CORS error
- “Access-Control-Allow-Origin” error
- Authentication fails silently
Solutions:
- Verify REDIRECT_URL matches website:
In docker-compose.yml:
- REDIRECT_URL=https://www.mysite.com
Must match your actual site URL EXACTLY:
- Same protocol (https)
- Same subdomain (www or not)
- No trailing slash
- Check CORS configuration in server.js:
app.use(cors({
origin: true, // Allows all origins
credentials: true
}));
This should already be correct in the code provided.
- Browser console details:
Open browser console (F12) when error occurs:
- Read the full error message
- Note which origin is being blocked
- Verify it matches your site
Issue 6: Organization Access Restrictions
Symptoms:
- Authentication succeeds
- Can’t save changes
- Error: “organization has enabled OAuth App access restrictions”
Solution:
Your repository is in a GitHub organization with OAuth restrictions:
- Go to: https://github.com/organizations/YOUR_ORG/settings/oauth_application_policy
- Or: https://github.com/settings/connections/applications
- Find your OAuth App
- Grant organization access
- If you’re org owner, access grants immediately
- If not, request access (admin must approve)
See detailed instructions in the multi-site guide.
Issue 7: Authentication Works But Can’t Save
Symptoms:
- Successfully logs in
- Can view content
- “Publish” or “Save” fails
- Error: “Failed to persist entry”
Solutions:
- Check GitHub permissions:
User must have write access to the repository:
- Go to:
https://github.com/username/repository/settings/access - Verify your account has “Write” or “Admin” access
- If not, add yourself as a collaborator
- Check branch name:
In config.yml:
backend:
branch: main # Must match actual branch name
If your default branch is master, change it to master.
- Check repository exists:
Verify the repo in config.yml is correct:
backend:
repo: username/repository # Check spelling!
- Check OAuth app scope:
OAuth app must have repo scope. Verify in server.js:
scope: 'repo,user', // 'repo' is required
Issue 8: Container Won’t Start
Symptoms:
docker compose up -dfails- Container keeps restarting
docker psshows container exiting
Solutions:
- Check logs:
docker compose logs
Look for specific error messages.
- Common startup errors:
Syntax error in server.js:
SyntaxError: Unexpected token
→ Check server.js for typos, missing commas, etc.
Missing dependencies:
Error: Cannot find module 'express'
→ Rebuild container: docker compose build --no-cache
Port already in use:
Error: listen EADDRINUSE: address already in use :::3000
→ Another container using port 3000 → Check: docker ps | grep 3000
- Test server.js syntax:
node -c server.js
Should return nothing if syntax is valid.
- Rebuild from scratch:
docker compose down
docker compose build --no-cache
docker compose up -d
docker compose logs -f
Security Considerations
Protecting Credentials
Client Secret is sensitive!
✅ DO:
- Store in
docker-compose.ymlonly - Set file permissions:
chmod 600 docker-compose.yml - Never commit to git with real secrets
- Keep backups encrypted
- Rotate periodically (every 6-12 months)
❌ DON’T:
- Hardcode in server.js
- Commit to public repositories
- Share via email/chat
- Log to console
- Store in plain text files
File Permissions
cd /opt/docker/decap-oauth
# Restrict access to docker-compose.yml
chmod 600 docker-compose.yml
# Verify
ls -l docker-compose.yml
# Should show: -rw------- (read/write for owner only)
Repository Access Control
Who can edit content:
- Anyone with write access to the GitHub repository
- Control via:
https://github.com/username/repo/settings/access
Best practices:
- Only add necessary collaborators
- Use teams for organization repos
- Remove access for former team members
- Review access regularly
OAuth App Security
Monitor your OAuth app:
- Go to: https://github.com/settings/developers
- Click your OAuth App
- Check Recent Delivery activity
- Look for unusual patterns
If compromised:
- Click “Revoke all user tokens”
- Generate new Client Secret
- Update docker-compose.yml
- Restart container
- All users need to re-authenticate
Network Security
Already secured by your setup:
- ✅ OAuth provider only accessible via HTTPS (Traefik)
- ✅ No direct port exposure (internal port 3000)
- ✅ SSL certificates automatic (Let’s Encrypt)
- ✅ Container isolated on Docker network
Additional measures:
- Firewall only allows ports 80, 443 (already done)
- SSH key-only authentication (already done)
- fail2ban monitoring (already done)
Maintenance
Regular Tasks
Check OAuth Provider Health
Weekly:
# From anywhere
curl https://oauth.mysite.com/health
# Should return healthy status
Monthly:
# SSH to VPS
cd /opt/docker/decap-oauth
# Check container status
docker compose ps
# Check logs for errors
docker compose logs --tail=100 | grep -i error
# Check disk space
df -h
Monitor Usage
View authentication activity:
cd /opt/docker/decap-oauth
docker compose logs | grep "Auth request"
docker compose logs | grep "Authentication successful"
Count authentications:
docker compose logs | grep "Authentication successful" | wc -l
Update Dependencies
Every 3-6 months:
- Update package.json:
cd /opt/docker/decap-oauth
nano package.json
Update versions to latest:
"dependencies": {
"express": "^4.19.2", // Check latest
"cors": "^2.8.5",
"simple-oauth2": "^5.1.0" // Check latest
}
- Rebuild container:
docker compose down
docker compose build --no-cache
docker compose up -d
- Test authentication:
- Go to your CMS
- Log in
- Verify it works
Check for updates:
- express: https://www.npmjs.com/package/express
- cors: https://www.npmjs.com/package/cors
- simple-oauth2: https://www.npmjs.com/package/simple-oauth2
Backup Configuration
Create backup:
cd /opt/docker/decap-oauth
# Create backup directory
mkdir -p ~/backups/decap-oauth
# Backup files (excluding secrets for security)
cp server.js ~/backups/decap-oauth/server.js.backup
cp package.json ~/backups/decap-oauth/package.json.backup
cp Dockerfile ~/backups/decap-oauth/Dockerfile.backup
# Note: Don't backup docker-compose.yml with secrets!
# Instead, store secrets separately and securely
Restore from backup:
cd /opt/docker/decap-oauth
docker compose down
# Restore files
cp ~/backups/decap-oauth/*.backup .
# Manually recreate docker-compose.yml with secrets
nano docker-compose.yml
docker compose up -d
Rotating Secrets
When to rotate:
- Every 6-12 months (scheduled)
- If secret may have been compromised
- When team members leave
- Before security audits
How to rotate:
- Generate new secret on GitHub:
- https://github.com/settings/developers
- Find your OAuth App
- “Generate a new client secret”
- Copy new secret
- Update docker-compose.yml:
cd /opt/docker/decap-oauth
nano docker-compose.yml
# Update OAUTH_CLIENT_SECRET
- Restart container:
docker compose restart
- Test:
- Users may need to re-authorize
- Test authentication flow works
Container Logs
View recent logs:
docker compose logs --tail=50
Follow logs live:
docker compose logs -f
Filter logs:
# Only errors
docker compose logs | grep -i error
# Only auth events
docker compose logs | grep -i auth
# Last hour
docker compose logs --since=1h
Log rotation:
- Docker automatically rotates logs
- Default: 10MB max per file, keeps 3 files
- No manual management needed
Multi-Site Decap CMS OAuth Provider
Initial Setup
This section converts your existing single-site OAuth provider to support multiple sites.
Step 1: Backup Current Configuration
Before making changes, backup your working setup:
cd /opt/docker/decap-oauth
cp server.js server.js.backup
cp docker-compose.yml docker-compose.yml.backup
If something goes wrong, you can restore:
cp server.js.backup server.js
cp docker-compose.yml.backup docker-compose.yml
Step 2: Update server.js for Multi-Site Support
Replace the contents of server.js:
nano server.js
New multi-site server.js:
const express = require('express');
const cors = require('cors');
const { AuthorizationCode } = require('simple-oauth2');
const app = express();
const port = process.env.PORT || 3000;
// CORS configuration - allow all origins
app.use(cors({
origin: true,
credentials: true
}));
// Parse query parameters
app.use(express.urlencoded({ extended: true }));
// ============================================
// CONFIGURATION: Add your sites here
// ============================================
const clients = {
// Site 1: Boeygembos (example)
boeygembos: {
client: new AuthorizationCode({
client: {
id: process.env.BOEYGEMBOS_CLIENT_ID,
secret: process.env.BOEYGEMBOS_CLIENT_SECRET,
},
auth: {
tokenHost: 'https://github.com',
tokenPath: '/login/oauth/access_token',
authorizePath: '/login/oauth/authorize',
},
}),
allowedOrigins: ['https://www.boeygembos.be'],
description: 'Boeygembos Forest Information Site'
},
// Site 2: Add your next site here (template)
// site2: {
// client: new AuthorizationCode({
// client: {
// id: process.env.SITE2_CLIENT_ID,
// secret: process.env.SITE2_CLIENT_SECRET,
// },
// auth: {
// tokenHost: 'https://github.com',
// tokenPath: '/login/oauth/access_token',
// authorizePath: '/login/oauth/authorize',
// },
// }),
// allowedOrigins: ['https://www.site2.com'],
// description: 'Site 2 Description'
// },
};
// ============================================
// Helper Functions
// ============================================
// Get client configuration by site name
function getClientBySite(siteName) {
return clients[siteName];
}
// Validate origin against allowed origins
function isOriginAllowed(siteName, origin) {
const clientConfig = getClientBySite(siteName);
if (!clientConfig) return false;
return clientConfig.allowedOrigins.includes(origin);
}
// ============================================
// Routes
// ============================================
// Root endpoint - shows configuration
app.get('/', (req, res) => {
const sites = Object.keys(clients).map(siteName => ({
name: siteName,
description: clients[siteName].description,
allowedOrigins: clients[siteName].allowedOrigins,
endpoints: {
auth: `/auth/${siteName}`,
callback: `/callback/${siteName}`
}
}));
res.json({
message: 'Multi-Site OAuth Provider for Decap CMS',
version: '2.0',
configured_sites: sites.length,
sites: sites,
health_check: '/health'
});
});
// Health check endpoint
app.get('/health', (req, res) => {
const siteCount = Object.keys(clients).length;
res.json({
status: 'healthy',
message: 'OAuth provider is running',
configured_sites: siteCount,
uptime: process.uptime()
});
});
// Auth endpoint - redirects to GitHub OAuth
app.get('/auth/:site', (req, res) => {
const siteName = req.params.site;
const clientConfig = getClientBySite(siteName);
if (!clientConfig) {
console.error(`Unknown site requested: ${siteName}`);
return res.status(400).json({
error: 'Unknown site',
message: `Site '${siteName}' is not configured`,
configured_sites: Object.keys(clients)
});
}
const authorizationUri = clientConfig.client.authorizeURL({
redirect_uri: `${process.env.BASE_URL}/callback/${siteName}`,
scope: 'repo,user',
state: req.query.state || '',
});
console.log(`Auth request for site: ${siteName}`);
res.redirect(authorizationUri);
});
// Callback endpoint - handles GitHub OAuth callback
app.get('/callback/:site', async (req, res) => {
const { code, state } = req.query;
const siteName = req.params.site;
const clientConfig = getClientBySite(siteName);
if (!clientConfig) {
console.error(`Unknown site in callback: ${siteName}`);
return res.status(400).json({
error: 'Unknown site',
message: `Site '${siteName}' is not configured`
});
}
try {
const tokenParams = {
code,
redirect_uri: `${process.env.BASE_URL}/callback/${siteName}`,
};
const accessToken = await clientConfig.client.getToken(tokenParams);
const token = accessToken.token.access_token;
// Get the origin from allowed origins
const redirectOrigin = clientConfig.allowedOrigins[0];
console.log(`Successfully authenticated for site: ${siteName}`);
// Send token back to CMS via postMessage
const script = `
<!DOCTYPE html>
<html>
<head>
<title>Authentication Successful</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.message {
text-align: center;
padding: 2rem;
background: rgba(255,255,255,0.1);
border-radius: 10px;
backdrop-filter: blur(10px);
}
</style>
</head>
<body>
<div class="message">
<h1>✓ Authentication Successful</h1>
<p>You can close this window.</p>
</div>
<script>
(function() {
function receiveMessage(e) {
console.log("receiveMessage %o", e);
// Send success message to opener
window.opener.postMessage(
'authorization:github:success:${JSON.stringify({ token, provider: 'github' })}',
e.origin
);
}
window.addEventListener("message", receiveMessage, false);
// Notify opener that we're authorizing
window.opener.postMessage("authorizing:github", "${redirectOrigin}");
// Auto-close after 2 seconds
setTimeout(function() {
window.close();
}, 2000);
})();
</script>
</body>
</html>
`;
res.send(script);
} catch (error) {
console.error(`Authentication failed for site ${siteName}:`, error.message);
res.status(500).send(`
<!DOCTYPE html>
<html>
<head>
<title>Authentication Failed</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.message {
text-align: center;
padding: 2rem;
background: rgba(255,255,255,0.1);
border-radius: 10px;
backdrop-filter: blur(10px);
}
.error {
background: rgba(0,0,0,0.2);
padding: 1rem;
border-radius: 5px;
margin-top: 1rem;
font-family: monospace;
font-size: 0.9em;
}
</style>
</head>
<body>
<div class="message">
<h1>✗ Authentication Failed</h1>
<p>There was an error during authentication.</p>
<div class="error">${error.message}</div>
<p style="margin-top: 1rem; font-size: 0.9em;">You can close this window and try again.</p>
</div>
</body>
</html>
`);
}
});
// ============================================
// Start Server
// ============================================
app.listen(port, () => {
const siteNames = Object.keys(clients);
console.log('='.repeat(50));
console.log('Multi-Site OAuth Provider for Decap CMS');
console.log('='.repeat(50));
console.log(`Server listening on port ${port}`);
console.log(`Base URL: ${process.env.BASE_URL}`);
console.log(`Configured sites: ${siteNames.length}`);
console.log('');
console.log('Sites:');
siteNames.forEach(siteName => {
const config = clients[siteName];
console.log(` - ${siteName}`);
console.log(` Description: ${config.description}`);
console.log(` Allowed origins: ${config.allowedOrigins.join(', ')}`);
console.log(` Auth endpoint: ${process.env.BASE_URL}/auth/${siteName}`);
});
console.log('='.repeat(50));
});
Save with Ctrl+X, Y, Enter.
Key changes from single-site version:
clientsobject holds multiple site configurations- Routes include
:siteparameter (e.g.,/auth/boeygembos) - Each site has its own credentials and allowed origins
- Root endpoint (
/) shows all configured sites - Better error handling and logging
Step 3: Update docker-compose.yml
nano docker-compose.yml
New multi-site docker-compose.yml:
version: '3.8'
services:
decap-oauth:
build: .
container_name: decap-oauth
restart: unless-stopped
networks:
- web
environment:
# Server configuration
- NODE_ENV=production
- PORT=3000
- BASE_URL=https://oauth.boeygembos.be
# Site 1: Boeygembos
- BOEYGEMBOS_CLIENT_ID=your_boeygembos_client_id_here
- BOEYGEMBOS_CLIENT_SECRET=your_boeygembos_client_secret_here
# Site 2: Add credentials for next site
# - SITE2_CLIENT_ID=xxx
# - SITE2_CLIENT_SECRET=xxx
# Site 3: Add credentials for another site
# - SITE3_CLIENT_ID=xxx
# - SITE3_CLIENT_SECRET=xxx
labels:
- "traefik.enable=true"
- "traefik.http.routers.decap-oauth.rule=Host(`oauth.boeygembos.be`)"
- "traefik.http.routers.decap-oauth.entrypoints=websecure"
- "traefik.http.routers.decap-oauth.tls.certresolver=letsencrypt"
- "traefik.http.services.decap-oauth.loadbalancer.server.port=3000"
networks:
web:
external: true
IMPORTANT: Replace your_boeygembos_client_id_here and your_boeygembos_client_secret_here with your actual GitHub OAuth App credentials.
Save with Ctrl+X, Y, Enter.
Step 4: Update Existing Site’s Decap CMS Config
Your existing site (Boeygembos) needs a small config update:
File: boeygembos-app/public/admin/config.yml
Change:
backend:
name: github
repo: boeygembos/boeygembos.github.io
branch: release
base_url: https://oauth.boeygembos.be
auth_endpoint: auth # Old way
To:
backend:
name: github
repo: boeygembos/boeygembos.github.io
branch: release
base_url: https://oauth.boeygembos.be
auth_endpoint: auth/boeygembos # New way - includes site name
Note: The only change is adding /boeygembos to the auth_endpoint.
Commit and push:
git add public/admin/config.yml
git commit -m "Update OAuth endpoint for multi-site support"
git push origin release
Step 5: Rebuild and Deploy
cd /opt/docker/decap-oauth
# Rebuild container with new code
docker compose down
docker compose build --no-cache
docker compose up -d
# Watch logs
docker compose logs -f
You should see:
==================================================
Multi-Site OAuth Provider for Decap CMS
==================================================
Server listening on port 3000
Base URL: https://oauth.boeygembos.be
Configured sites: 1
Sites:
- boeygembos
Description: Boeygembos Forest Information Site
Allowed origins: https://www.boeygembos.be
Auth endpoint: https://oauth.boeygembos.be/auth/boeygembos
==================================================
Press Ctrl+C to exit logs.
Step 6: Test Existing Site
- Wait ~2 minutes for GitHub Actions to deploy the config change
- Go to: https://www.boeygembos.be/admin/
- Click “Login with GitHub”
- Should work exactly as before ✅
If it doesn’t work, check Troubleshooting section.
Step 7: Verify Multi-Site Setup
Check the provider info endpoint:
curl https://oauth.boeygembos.be/ | jq
Should return:
{
"message": "Multi-Site OAuth Provider for Decap CMS",
"version": "2.0",
"configured_sites": 1,
"sites": [
{
"name": "boeygembos",
"description": "Boeygembos Forest Information Site",
"allowedOrigins": ["https://www.boeygembos.be"],
"endpoints": {
"auth": "/auth/boeygembos",
"callback": "/callback/boeygembos"
}
}
],
"health_check": "/health"
}
Adding Additional Sites
Now that you have multi-site support, adding new sites is straightforward.
Overview: 5-Step Process
For each new site:
- Create GitHub OAuth App
- Add site to
server.js - Add credentials to
docker-compose.yml - Restart container
- Configure site’s Decap CMS
Time per site: ~15 minutes
Example: Adding “Client Portfolio” Site
Let’s walk through adding a second site: www.clientportfolio.com
Step 1: Create GitHub OAuth App
- Go to: https://github.com/settings/developers
- Click New OAuth App
- Fill in:
- Application name:
Client Portfolio CMS - Homepage URL:
https://www.clientportfolio.com - Authorization callback URL:
https://oauth.boeygembos.be/callback/clientportfolio- ⚠️ Important: Use site’s short name in path (no spaces, lowercase)
- Application name:
- Click Register application
- Copy Client ID
- Click Generate a new client secret
- Copy Client Secret
Save these somewhere secure!
Step 2: Add Site to server.js
SSH to VPS and edit server.js:
cd /opt/docker/decap-oauth
nano server.js
Find the clients object and add your new site:
const clients = {
// Existing site
boeygembos: {
client: new AuthorizationCode({
client: {
id: process.env.BOEYGEMBOS_CLIENT_ID,
secret: process.env.BOEYGEMBOS_CLIENT_SECRET,
},
auth: {
tokenHost: 'https://github.com',
tokenPath: '/login/oauth/access_token',
authorizePath: '/login/oauth/authorize',
},
}),
allowedOrigins: ['https://www.boeygembos.be'],
description: 'Boeygembos Forest Information Site'
},
// NEW SITE - Add this
clientportfolio: {
client: new AuthorizationCode({
client: {
id: process.env.CLIENTPORTFOLIO_CLIENT_ID,
secret: process.env.CLIENTPORTFOLIO_CLIENT_SECRET,
},
auth: {
tokenHost: 'https://github.com',
tokenPath: '/login/oauth/access_token',
authorizePath: '/login/oauth/authorize',
},
}),
allowedOrigins: ['https://www.clientportfolio.com'],
description: 'Client Portfolio Website'
},
};
Naming conventions:
- Object key:
clientportfolio(lowercase, no spaces - used in URL paths) - Environment variables:
CLIENTPORTFOLIO_CLIENT_ID(uppercase with underscores) - Description: Human-readable name
Save with Ctrl+X, Y, Enter.
Step 3: Add Credentials to docker-compose.yml
nano docker-compose.yml
Add environment variables:
environment:
# Server configuration
- NODE_ENV=production
- PORT=3000
- BASE_URL=https://oauth.boeygembos.be
# Site 1: Boeygembos
- BOEYGEMBOS_CLIENT_ID=xxx
- BOEYGEMBOS_CLIENT_SECRET=xxx
# Site 2: Client Portfolio - ADD THESE
- CLIENTPORTFOLIO_CLIENT_ID=paste_client_id_here
- CLIENTPORTFOLIO_CLIENT_SECRET=paste_client_secret_here
Replace paste_client_id_here and paste_client_secret_here with actual values from Step 1.
Save with Ctrl+X, Y, Enter.
Step 4: Rebuild and Restart Container
docker compose down
docker compose build --no-cache
docker compose up -d
# Check logs - should show 2 configured sites
docker compose logs --tail=30
Verify it’s configured:
curl https://oauth.boeygembos.be/ | jq
Should show 2 sites now.
Step 5: Configure New Site’s Decap CMS
In your Client Portfolio repository, edit the Decap CMS config:
File: public/admin/config.yml (or wherever your config is)
backend:
name: github
repo: username/clientportfolio-repo # Your actual repo
branch: main # Your branch
base_url: https://oauth.boeygembos.be
auth_endpoint: auth/clientportfolio # Match the key from server.js
media_folder: "public/images"
public_folder: "/images"
collections:
# Your collections here
Key points:
base_urlpoints to the shared OAuth providerauth_endpointincludes the site name:auth/clientportfolio- Site name must match the key in
server.jsclients object
Commit, push, and deploy the site.
Step 6: Test New Site
- Go to:
https://www.clientportfolio.com/admin/ - Click “Login with GitHub”
- Should redirect to GitHub
- Authorize
- Should be logged in ✅
Quick Add Template
For future sites, use this template:
1. GitHub OAuth App:
- Homepage:
https://www.YOURSITE.com - Callback:
https://oauth.boeygembos.be/callback/SITENAME
2. server.js addition:
SITENAME: {
client: new AuthorizationCode({
client: {
id: process.env.SITENAME_CLIENT_ID,
secret: process.env.SITENAME_CLIENT_SECRET,
},
auth: {
tokenHost: 'https://github.com',
tokenPath: '/login/oauth/access_token',
authorizePath: '/login/oauth/authorize',
},
}),
allowedOrigins: ['https://www.YOURSITE.com'],
description: 'SITE DESCRIPTION'
},
3. docker-compose.yml addition:
- SITENAME_CLIENT_ID=xxx
- SITENAME_CLIENT_SECRET=xxx
4. Site’s config.yml:
backend:
base_url: https://oauth.boeygembos.be
auth_endpoint: auth/SITENAME
5. Restart:
docker compose down && docker compose build --no-cache && docker compose up -d
Configuration Reference
Environment Variable Naming
Pattern: SITENAME_CREDENTIAL_TYPE
Where:
SITENAME= Uppercase version of site keyCREDENTIAL_TYPE=CLIENT_IDorCLIENT_SECRET
Examples:
BOEYGEMBOS_CLIENT_ID
BOEYGEMBOS_CLIENT_SECRET
CLIENTSITE_CLIENT_ID
CLIENTSITE_CLIENT_SECRET
MYPORTFOLIO_CLIENT_ID
MYPORTFOLIO_CLIENT_SECRET
Site Key Naming Conventions
Good site keys:
boeygembos✅clientsite✅portfolio2024✅myblog✅
Bad site keys:
Client Site❌ (spaces)my-blog❌ (hyphens cause issues)Site#2❌ (special characters)2ndsite❌ (starts with number)
Rules:
- Lowercase only
- No spaces
- No special characters
- Use letters and numbers only
- Can’t start with a number
Callback URL Format
Pattern: https://oauth.boeygembos.be/callback/SITENAME
Where SITENAME matches the key in server.js.
Examples:
https://oauth.boeygembos.be/callback/boeygembos
https://oauth.boeygembos.be/callback/clientsite
https://oauth.boeygembos.be/callback/portfolio2024
Decap CMS Config Format
Minimal required config:
backend:
name: github
repo: username/repository
branch: main
base_url: https://oauth.boeygembos.be
auth_endpoint: auth/SITENAME
With additional options:
backend:
name: github
repo: username/repository
branch: main
base_url: https://oauth.boeygembos.be
auth_endpoint: auth/SITENAME
open_authoring: true # Optional: enable open authoring
squash_merges: true # Optional: squash commits
Troubleshooting
Issue 1: “Unknown site” Error
Symptoms:
- Clicking “Login with GitHub” shows error page
- Error message: “Site ‘xyz’ is not configured”
Causes:
- Site key mismatch between
server.jsandauth_endpoint - Typo in site name
- Forgot to restart container after adding site
Solutions:
- Check site key matches:
// In server.js
const clients = {
mysite: { ... } // Key is 'mysite'
};
# In site's config.yml
auth_endpoint: auth/mysite # Must match exactly
- Verify container has latest code:
cd /opt/docker/decap-oauth
docker compose down
docker compose build --no-cache
docker compose up -d
docker compose logs | grep "Configured sites"
- Check configured sites:
curl https://oauth.boeygembos.be/ | jq '.sites[].name'
Issue 2: Authentication Fails with 401
Symptoms:
- Redirects to GitHub successfully
- Authorization fails with error
- Console shows 401 or “Bad credentials”
Causes:
- Wrong Client ID or Secret in
docker-compose.yml - Client Secret was regenerated on GitHub
- Environment variables not loaded
Solutions:
- Verify credentials in GitHub:
- Go to: https://github.com/settings/developers
- Find your OAuth App
- Check Client ID matches
- If needed, regenerate Client Secret
- Update docker-compose.yml:
nano docker-compose.yml
# Update the credentials
docker compose down
docker compose up -d
- Check environment variables loaded:
docker exec decap-oauth env | grep CLIENT
Should show all your CLIENT_ID and CLIENT_SECRET variables.
Issue 3: CORS Error in Browser Console
Symptoms:
- Browser console shows CORS error
- “Access-Control-Allow-Origin” error
- Authentication popup closes immediately
Causes:
- Site origin not in
allowedOriginsarray - HTTP vs HTTPS mismatch
- Wrong domain in allowedOrigins
Solutions:
- Check allowedOrigins in server.js:
allowedOrigins: ['https://www.yoursite.com'] // Must match exactly
Must match:
- Protocol (http vs https)
- Subdomain (www vs non-www)
- Domain spelling
- For development (testing locally):
Add localhost to allowedOrigins temporarily:
allowedOrigins: [
'https://www.yoursite.com',
'http://localhost:3000' // For local testing
]
Remember to remove localhost in production!
Issue 4: Container Won’t Start
Symptoms:
docker compose up -dfails- Container keeps restarting
- Can’t access OAuth provider
Causes:
- Syntax error in server.js
- Missing dependencies
- Port conflict
Solutions:
- Check logs for errors:
docker compose logs
- Common errors:
Syntax Error:
SyntaxError: Unexpected token
→ Check server.js for missing commas, brackets, etc.
Module not found:
Error: Cannot find module 'simple-oauth2'
→ Rebuild container:
docker compose build --no-cache
Port in use:
Error starting userland proxy: listen tcp 0.0.0.0:3000: bind: address already in use
→ Another container using port 3000. Check with:
docker ps | grep 3000
- Test server.js syntax locally:
node -c server.js
Should return nothing if syntax is valid.
Issue 5: GitHub OAuth App Restrictions
Symptoms:
- Error: “organization has enabled OAuth App access restrictions”
- Authentication fails after authorization
Cause:
- Organization has strict OAuth policies
Solution:
- Go to: https://github.com/organizations/ORG_NAME/settings/oauth_application_policy
- Find your OAuth App in the list
- Click Grant access or Request access
- If you’re org owner, access grants immediately
- If not, org admin must approve
Issue 6: Sites Work Individually but Not Together
Symptoms:
- Site 1 works fine
- Adding Site 2 breaks Site 1
- Authentication fails for both
Causes:
- Duplicate site keys
- Environment variable conflicts
- Syntax error breaking all sites
Solutions:
- Check for duplicate keys:
cd /opt/docker/decap-oauth
grep -n "^ [a-z]*:" server.js
Each site should appear only once.
- Test configuration endpoint:
curl https://oauth.boeygembos.be/ | jq
Should list all sites. If it errors, there’s a syntax problem.
- Roll back to working version:
cp server.js.backup server.js
docker compose restart
Then add sites one at a time, testing after each.
Maintenance
Monitoring
Check Provider Status
# Health check
curl https://oauth.boeygembos.be/health
# List configured sites
curl https://oauth.boeygembos.be/ | jq '.sites'
# View container logs
cd /opt/docker/decap-oauth
docker compose logs --tail=50
Watch Live Traffic
docker compose logs -f
You’ll see:
- Auth requests:
Auth request for site: sitename - Successful auth:
Successfully authenticated for site: sitename - Errors: Any authentication failures
Check Container Health
docker ps | grep decap-oauth
# Should show:
# - STATUS: Up X minutes/hours
# - PORTS: Not externally exposed (Traefik handles routing)
Updating Sites
Modify Existing Site
- Update server.js (e.g., change allowedOrigins)
nano /opt/docker/decap-oauth/server.js
# Make changes
- Restart container:
docker compose restart
No rebuild needed for config changes.
Remove a Site
- Delete from server.js:
// Remove the entire site object
- Remove from docker-compose.yml:
# Remove SITENAME_CLIENT_ID and SITENAME_CLIENT_SECRET
- Restart:
docker compose down
docker compose build --no-cache
docker compose up -d
- Revoke GitHub OAuth App (optional but recommended):
- Go to: https://github.com/settings/developers
- Find the OAuth App
- Click Delete
Rotating Secrets
If a Client Secret is compromised:
- Generate new secret on GitHub:
- Settings → Developer settings → OAuth Apps
- Find the app
- Click Revoke all user tokens (optional but recommended)
- Click Generate a new client secret
- Copy new secret
- Update docker-compose.yml:
nano docker-compose.yml
# Update SITENAME_CLIENT_SECRET
- Restart container:
docker compose restart
- Test authentication:
- Users will need to re-authorize
- Existing sessions will be invalidated
Backing Up Configuration
Backup regularly:
cd /opt/docker/decap-oauth
# Create backup directory
mkdir -p ../backups/decap-oauth
# Backup with timestamp
DATE=$(date +%Y%m%d-%H%M%S)
tar -czf ../backups/decap-oauth/backup-$DATE.tar.gz .
# Keep last 10 backups
cd ../backups/decap-oauth
ls -t | tail -n +11 | xargs rm -f
Restore from backup:
cd /opt/docker/decap-oauth
docker compose down
# Extract backup (replace DATE with actual backup)
tar -xzf ../backups/decap-oauth/backup-DATE.tar.gz
docker compose up -d
Updating Dependencies
Check for updates:
cd /opt/docker/decap-oauth
# Check current versions
docker exec decap-oauth npm list --depth=0
# Update package.json with latest versions
nano package.json
Update and rebuild:
# Update package.json
nano package.json
# Rebuild
docker compose down
docker compose build --no-cache
docker compose up -d
# Test all sites
Test thoroughly after dependency updates!
Logs Management
View logs:
docker compose logs --tail=100
docker compose logs -f # Follow mode
docker compose logs --since=1h # Last hour
Log rotation (handled by Docker automatically):
- Default: Logs rotate at 10MB
- Keeps last 3 files
- No action needed
Manual cleanup (if needed):
docker compose down
docker system prune -a # Remove all unused data
docker compose up -d
Security Best Practices
Secrets Management
✅ DO:
- Store Client Secrets in docker-compose.yml only
- Set file permissions:
chmod 600 docker-compose.yml - Keep backups secure and encrypted
- Rotate secrets periodically (every 6-12 months)
- Use different credentials for each site
❌ DON’T:
- Commit docker-compose.yml to git with secrets
- Share Client Secrets via insecure channels
- Reuse same credentials across sites
- Store secrets in plain text files
- Log secrets to console
Access Control
GitHub Repository Access:
- Only add collaborators who need CMS access
- Review access regularly
- Remove access for former team members
- Use teams instead of individual access when possible
OAuth App Access:
- Don’t approve unknown OAuth apps
- Review authorized apps: https://github.com/settings/applications
- Revoke unused apps
- Monitor organization OAuth policies
Network Security
Container Isolation:
- OAuth provider only on
webnetwork - No direct port exposure (3000 is internal only)
- Traffic always through Traefik (HTTPS)
Firewall:
- Only ports 80, 443 open on VPS
- SSH restricted to key-only auth
- fail2ban monitoring enabled
Monitoring
Watch for:
- Unusual authentication patterns
- Failed authentication attempts
- Unknown sites in logs
- Unexpected traffic spikes
Set up alerts (optional but recommended):
- UptimeRobot for provider availability
- GitHub notifications for OAuth app changes
- Log monitoring for errors
Performance Optimization
Current Performance
Typical metrics:
- Authentication time: 1-2 seconds
- Memory usage: ~50-100 MB
- CPU usage: Minimal (< 1%)
- Can handle 50+ sites easily
Scaling Considerations
When to optimize:
- 50+ configured sites
- High traffic (1000+ auth/day)
- Slow authentication times
- Memory issues
Optimization strategies:
- Enable caching (for very high traffic):
// Add to server.js
const NodeCache = require('node-cache');
const tokenCache = new NodeCache({ stdTTL: 600 }); // 10 min cache
- Rate limiting (prevent abuse):
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // limit each IP to 100 requests per windowMs
});
app.use('/auth/', limiter);
- Horizontal scaling (load balancing):
- Deploy multiple OAuth provider instances
- Use Traefik load balancing
- Share state via Redis
For most use cases, no optimization needed!
Advanced Configurations
Multiple Domains for Same Site
If a site is accessible via multiple domains:
allowedOrigins: [
'https://www.site.com',
'https://site.com',
'https://staging.site.com'
]
Different Git Providers
GitLab example:
clientsite: {
client: new AuthorizationCode({
client: {
id: process.env.CLIENTSITE_CLIENT_ID,
secret: process.env.CLIENTSITE_CLIENT_SECRET,
},
auth: {
tokenHost: 'https://gitlab.com', // Changed
tokenPath: '/oauth/token', // Changed
authorizePath: '/oauth/authorize', // Changed
},
}),
allowedOrigins: ['https://www.clientsite.com'],
description: 'Client Site (GitLab)'
}
Bitbucket similar to GitLab.
Custom Authentication Messages
Customize the callback HTML in server.js:
// In /callback/:site route
const script = `
<!DOCTYPE html>
<html>
<head>
<title>Welcome to ${siteName}!</title>
<style>
/* Your custom styles */
</style>
</head>
<body>
<div class="message">
<h1>✓ Welcome!</h1>
<p>Successfully logged into ${clientConfig.description}</p>
</div>
<script>
// postMessage code here
</script>
</body>
</html>
`;
Logging to External Service
Add structured logging:
// At top of server.js
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
});
// Use in routes
logger.info('Auth request', { site: siteName, timestamp: new Date() });
Migration Guide
From Single-Site to Multi-Site
If you have existing single-site OAuth providers and want to consolidate:
- Inventory existing providers:
# List all OAuth containers
docker ps -a | grep oauth
- Document each site’s credentials:
- GitHub OAuth App Client ID
- GitHub OAuth App Client Secret
- Site domain
- Repository
- Set up multi-site provider (follow Initial Setup in this guide)
- Add all sites to multi-site config
- Update each site’s Decap CMS config:
base_url: https://oauth.boeygembos.be
auth_endpoint: auth/SITENAME
- Test each site individually
- Remove old single-site containers:
cd /opt/docker/old-oauth-provider
docker compose down
- Update DNS if needed (if using site-specific OAuth domains)
From Netlify/External OAuth
If migrating from Netlify or another OAuth service:
- Create GitHub OAuth Apps for each site
- Deploy multi-site provider on VPS
- Update Decap CMS configs to point to your provider
- Test thoroughly before removing old OAuth
- Update in batches to minimize risk
Cost Analysis
Resource Usage
Single OAuth provider serving 10 sites:
- Memory: ~100 MB
- CPU: < 0.5%
- Disk: ~50 MB
- Network: Minimal
Vs. 10 separate OAuth containers:
- Memory: ~1 GB (10x)
- CPU: ~5% (10x)
- Disk: ~500 MB (10x)
- Management overhead: 10x
Savings
VPS resources saved:
- Can host 50+ sites vs 10 with separate containers
- Lower memory pressure
- Faster deployments
- Simpler architecture
Time saved:
- Add new site: 15 min vs 45 min
- Updates: One change vs updating each container
- Monitoring: One service vs many
Troubleshooting Decision Tree
Authentication not working?
├─ Error: "Unknown site"
│ ├─ Check site key matches in server.js and config.yml
│ └─ Verify container restarted after adding site
│
├─ Error: 401 Unauthorized
│ ├─ Verify Client ID/Secret in docker-compose.yml
│ └─ Check if secret was regenerated on GitHub
│
├─ CORS Error in browser
│ ├─ Check allowedOrigins includes correct domain
│ └─ Verify protocol (http vs https) matches
│
├─ Popup closes immediately
│ └─ Check browser console for errors
│
└─ OAuth provider not accessible
├─ Check DNS: dig oauth.boeygembos.be
├─ Check container: docker ps | grep decap-oauth
└─ Check logs: docker compose logs
FAQ
Q: How many sites can one provider handle?
A: Theoretically unlimited. Practically tested with 50+ sites without issues. Limited only by VPS resources.
Q: Can I use different OAuth providers (GitHub, GitLab) together?
A: Yes! Each site in the clients object can configure different OAuth providers.
Q: What if one site’s OAuth App is compromised?
A: Only that site is affected. Regenerate its Client Secret and update docker-compose.yml. Other sites unaffected.
Q: Can I use custom domains for the OAuth provider?
A: Yes, but not recommended. Using oauth.boeygembos.be for all sites simplifies management. If needed, configure multiple Traefik routes.
Q: Do users need to authorize each time?
A: No. GitHub stores authorization. Users only authorize once per OAuth App (unless token expires or is revoked).
Q: Can I restrict which GitHub users can access each site?
A: Yes, via GitHub repository permissions. Only users with write access to the repo can save changes via CMS.
Q: What happens if the OAuth provider is down?
A: Users can’t log into CMS, but websites remain accessible. OAuth only needed for CMS authentication, not site access.
Q: Can I test locally before deploying?
A: Yes. Run the OAuth provider locally with ngrok or similar tunnel, then update base_url in test site’s config.yml to point to your local instance.
Services
services
${template.each(query[[ from p = index.tag “page” where string.find(p.name, editor.getCurrentPage() .. “/”, 1, true) ]], templates.pageItem)}
Self-Hosted Authentik SSO Setup Guide
A complete guide to setting up Authentik as a self-hosted Single Sign-On (SSO) and OAuth2/OIDC identity provider with YubiKey MFA support.
Table of Contents
- Overview
- Prerequisites
- Installation
- Reverse Proxy Configuration
- Initial Setup
- YubiKey MFA Configuration
- Integration with Applications
- Troubleshooting
Overview
What is Authentik?
Authentik is a powerful, open-source identity provider (IdP) that provides:
- Single Sign-On (SSO) for multiple applications
- OAuth2 and OIDC provider capabilities
- Multi-Factor Authentication (MFA) including WebAuthn/FIDO2 for hardware keys
- User management with flows and policies
- LDAP and SAML support
Use Cases:
- Centralized authentication for self-hosted services
- Replace cloud providers like Auth0 or Okta
- Hardware security key (YubiKey) authentication
- Fine-grained access control with policies
Prerequisites
Required Infrastructure
- Linux server (VPS or homelab) with Docker
- Reverse proxy (Traefik, Caddy, nginx, or Pangolin)
- Domain name with DNS configured (e.g.,
auth.example.com) - SSL/TLS certificates (handled by reverse proxy)
Required Software
- Docker and Docker Compose
- Container orchestration tool (Dockge, Portainer, or docker-compose CLI)
System Requirements
- RAM: Minimum 1GB, recommended 2GB+
- Storage: ~500MB for containers, additional space for database
- CPU: 1 core minimum, 2+ recommended
Installation
Step 1: Prepare Directory Structure
Create the configuration directory:
# Using /opt/configs as the base path
mkdir -p /opt/configs/authentik/{database,redis,media,certs,custom-templates}
Note: If you use a different base path, adjust the CONFIG_PATH environment variable accordingly.
Step 2: Generate Secret Key
Generate a secure random secret key for Authentik:
openssl rand -base64 60
Save this key - you’ll need it in the next step.
Step 3: Create Docker Compose File
Create /opt/stacks/authentik/compose.yaml (or use your container orchestration tool):
services:
postgresql:
image: docker.io/library/postgres:16-alpine
container_name: authentik-db
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
start_period: 20s
interval: 30s
retries: 5
timeout: 5s
volumes:
- ${CONFIG_PATH}/authentik/database:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: ${PG_PASS:?database password required}
POSTGRES_USER: ${PG_USER:-authentik}
POSTGRES_DB: ${PG_DB:-authentik}
networks:
- authentik-internal
redis:
image: docker.io/library/redis:alpine
container_name: authentik-redis
command: --save 60 1 --loglevel warning
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
start_period: 20s
interval: 30s
retries: 5
timeout: 3s
volumes:
- ${CONFIG_PATH}/authentik/redis:/data
networks:
- authentik-internal
server:
image: ghcr.io/goauthentik/server:2024.12.1
container_name: authentik
restart: unless-stopped
command: server
environment:
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
AUTHENTIK_COOKIE_DOMAIN: ${AUTHENTIK_DOMAIN:-auth.example.com}
volumes:
- ${CONFIG_PATH}/authentik/media:/media
- ${CONFIG_PATH}/authentik/custom-templates:/templates
ports:
- "9000:9000"
- "9443:9443"
depends_on:
- postgresql
- redis
networks:
- authentik-internal
- web # External network for reverse proxy
worker:
image: ghcr.io/goauthentik/server:2024.12.1
container_name: authentik-worker
restart: unless-stopped
command: worker
environment:
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
user: root
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${CONFIG_PATH}/authentik/media:/media
- ${CONFIG_PATH}/authentik/certs:/certs
- ${CONFIG_PATH}/authentik/custom-templates:/templates
depends_on:
- postgresql
- redis
networks:
- authentik-internal
networks:
authentik-internal:
web:
external: true # Reverse proxy network
Step 4: Create Environment File
Create a .env file in the same directory:
# Config directory path
CONFIG_PATH=/opt/configs
# PostgreSQL credentials
PG_PASS=your-secure-postgres-password-here
PG_USER=authentik
PG_DB=authentik
# Authentik configuration
AUTHENTIK_SECRET_KEY=paste-your-generated-secret-key-here
AUTHENTIK_DOMAIN=auth.example.com
Important: Replace the placeholder values with:
- A strong PostgreSQL password
- The secret key you generated in Step 2
- Your actual domain name
Step 5: Start Authentik
cd /opt/stacks/authentik
docker compose up -d
Watch the logs to ensure migrations complete:
docker logs authentik-worker -f
You should see database migrations running. This is normal on first startup and may take 2-5 minutes. Wait for them to complete.
Reverse Proxy Configuration
Authentik requires a reverse proxy to handle SSL/TLS termination. Here are examples for common reverse proxies.
Pangolin
If using Pangolin reverse proxy:
- Create a new resource in Pangolin
- Configure:
- Domain:
auth.example.com - Target Address:
authentik(container name) - Target Port:
9000 - Authentication: Disabled (Authentik handles its own auth)
- SSL/HTTPS: Enabled
- Domain:
Important: The authentik container must be on the same Docker network as Pangolin (typically called web).
Traefik (File Provider)
Add to your dynamic_config.yml:
http:
routers:
authentik:
rule: "Host(`auth.example.com`)"
service: authentik
entryPoints:
- websecure
tls:
certResolver: letsencrypt
services:
authentik:
loadBalancer:
servers:
- url: "http://authentik:9000"
Traefik (Docker Provider)
Add labels to the server container:
server:
# ... existing config ...
labels:
- "traefik.enable=true"
- "traefik.http.routers.authentik.rule=Host(`auth.example.com`)"
- "traefik.http.routers.authentik.entrypoints=websecure"
- "traefik.http.routers.authentik.tls.certresolver=letsencrypt"
- "traefik.http.services.authentik.loadbalancer.server.port=9000"
Nginx
server {
listen 443 ssl http2;
server_name auth.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:9000;
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;
}
}
Caddy
auth.example.com {
reverse_proxy authentik:9000
}
Initial Setup
Step 1: Access Initial Setup Flow
Navigate to: https://auth.example.com/if/flow/initial-setup/
Note: The path /if/flow/initial-setup/ is important - don’t forget it!
Step 2: Create Admin Account
Fill in the form:
- Email: Your email address
- Username: Choose an admin username (e.g.,
adminor your name) - Password: Strong password (you can change this later or disable password auth after setting up YubiKey)
Click Continue
Step 3: Verify Installation
After creating the admin account, you’ll be logged in. You should see the Authentik admin interface.
Navigate through:
- Dashboard: Overview of your Authentik instance
- Directory → Users: Shows your admin user
- Applications: Empty for now (you’ll add apps here later)
YubiKey MFA Configuration
Step 1: Navigate to User Settings
- Click your username in the top right
- Select Settings
Step 2: Enroll First YubiKey
- Scroll to the MFA Devices section
- Click Enroll new device
- Select WebAuthn device (not TOTP)
- WebAuthn/FIDO2 is the modern standard for hardware security keys
- TOTP is for app-based authenticators like Google Authenticator
- Give it a friendly name (e.g., “YubiKey 5 - Primary”)
- Click Enroll
- Your browser will prompt you to insert and touch your YubiKey
- Touch the YubiKey when it blinks
Step 3: Enroll Backup YubiKey (Recommended)
Repeat the enrollment process for a second YubiKey:
- Enroll new device → WebAuthn device
- Name it (e.g., “YubiKey 5 - Backup”)
- Touch the second YubiKey when prompted
Why a backup? If you lose or break your primary YubiKey and haven’t enrolled a backup, you could be locked out.
Step 4: Make MFA Mandatory (Optional)
Warning: Only do this after successfully testing MFA login with your YubiKey!
-
Navigate to Flows & Stages
-
Find default-authentication-flow
-
Click the ▶ (play/execute) icon to view stage bindings
-
You’ll see stages:
- Order 10: Identification
- Order 20: Password
- Order 30: MFA Validation
- Order 100: User Login
-
Click on default-authentication-mfa-validation stage (Order 30)
-
Click Edit Stage (not Edit Binding)
-
In the stage configuration:
- Device classes: Ensure “WebAuthn” is selected
- Other settings can remain default
-
Click Update
-
To make MFA required (not optional):
- Click Edit Binding on the MFA Validation stage
- Change Order to ensure it runs after password
- Set Required to true if available, or
- Create a policy that requires MFA for all users
Step 5: Test MFA Login
Before making MFA mandatory, test it works:
- Log out of Authentik
- Log back in with username and password
- You should be prompted for MFA
- Insert your YubiKey and touch it
- You should be logged in successfully
If this works, you can safely make MFA mandatory.
Integration with Applications
Creating an OAuth2/OIDC Provider
Authentik can provide SSO for other applications using OAuth2/OIDC. Here’s how:
Step 1: Create Provider
- Navigate to Applications → Providers
- Click Create
- Select OAuth2/OpenID Provider
- Configure:
- Name: Application name (e.g., “Netbird”)
- Authentication flow:
default-authentication-flow - Authorization flow:
default-provider-authorization-explicit-consent - Client type:
Confidential - Redirect URIs/Origins (RegEx):
https://app.example.com/oauth2/callback https://app.example.com/auth - Signing Key:
authentik Self-signed Certificate
- Click Create
- Copy the Client ID and Client Secret - you’ll need these for your application
Step 2: Create Application
- Navigate to Applications → Applications
- Click Create
- Configure:
- Name: Application name
- Slug:
app-name(lowercase, no spaces) - Provider: Select the provider you just created
- Click Create
Step 3: Configure Your Application
In your application’s settings, configure OAuth/OIDC:
- Provider Type: Generic OIDC or Authentik (if available)
- Issuer URL:
https://auth.example.com/application/o/app-name/ - Client ID: From Step 1
- Client Secret: From Step 1
- Scopes:
openid profile email
Note: The Issuer URL format is important - it must end with /application/o/<slug>/
Example: Integrating with Netbird
# Netbird configuration
IdpManagerConfig:
ManagerType: "authentik"
ClientConfig:
Issuer: "https://auth.example.com/application/o/netbird/"
ClientID: "your-client-id"
ClientSecret: "your-client-secret"
RedirectURLs:
- "https://netbird.example.com/oauth2/callback"
Scopes:
- "openid"
- "profile"
- "email"
Troubleshooting
Container Startup Issues
Migrations taking a long time:
- This is normal on first startup (2-5 minutes)
- Watch logs:
docker logs authentik-worker -f - Migrations should complete and show “OK”
Containers won’t start:
# Check all containers
docker ps -a | grep authentik
# View logs
docker logs authentik
docker logs authentik-worker
docker logs authentik-db
docker logs authentik-redis
Reverse Proxy Issues
502 Bad Gateway:
- Verify Authentik is running:
docker ps | grep authentik - Test locally:
curl http://localhost:9000/-/health/live/- Should return:
{"status": "ok"}
- Should return:
- If using containerized reverse proxy:
- Ensure both are on the same Docker network
- Use container name instead of
localhost - Example:
http://authentik:9000instead ofhttp://localhost:9000
SSL/Certificate errors:
- Verify your reverse proxy SSL configuration
- Check DNS points to correct server
- Test with
curl -v https://auth.example.com
Authentication Issues
Can’t access initial setup:
- Ensure you’re accessing:
https://auth.example.com/if/flow/initial-setup/ - Check reverse proxy is forwarding correctly
- Verify
AUTHENTIK_COOKIE_DOMAINmatches your domain
Locked out after enabling MFA:
- If you didn’t enroll a backup device, you’ll need to reset
- Access PostgreSQL database and disable MFA:
docker exec -it authentik-db psql -U authentik authentik - Better: Always enroll 2+ YubiKeys before making MFA mandatory
YubiKey not recognized:
- Ensure browser supports WebAuthn (Chrome, Firefox, Edge do)
- Check YubiKey is inserted properly
- Try a different USB port
- Test YubiKey at https://demo.yubico.com/webauthn
Network Configuration
Containers can’t communicate:
# Verify network exists
docker network ls | grep web
# Inspect network
docker network inspect web
# Verify authentik is on the network
docker network inspect web | grep authentik
Fix: Container not on network
# Stop stack
docker compose down
# Update compose.yaml to include web network
# See Installation section for correct configuration
# Restart
docker compose up -d
Advanced Configuration
Custom Flows
Authentik uses “flows” to control authentication, authorization, and enrollment processes. You can customize these:
- Navigate to Flows & Stages
- Duplicate existing flows to create custom versions
- Add/remove stages to modify behavior
- Bind policies to stages for conditional logic
User Self-Service Registration
Enable users to create their own accounts:
- Navigate to Flows & Stages → Flows
- Find
default-source-enrollmentflow - Click Edit
- Configure stages to collect user information
- Set Create users as inactive if you want admin approval
- Share enrollment URL:
https://auth.example.com/if/flow/default-source-enrollment/
LDAP Support
Authentik can act as an LDAP server:
- Navigate to Applications → Outposts
- Create LDAP outpost
- Bind to users and groups
- Configure client applications to use Authentik LDAP
SAML Provider
For applications requiring SAML instead of OIDC:
- Navigate to Applications → Providers
- Create SAML Provider
- Configure metadata URL
- Set ACS URL and audience from your application
Security Best Practices
- Use strong passwords for PostgreSQL and Authentik admin
- Enable MFA for all users, especially administrators
- Enroll multiple YubiKeys for redundancy
- Regular backups of
/opt/configs/authentik/database - Update regularly:
docker compose pull && docker compose up -d - Monitor logs for suspicious activity
- Use separate networks for internal communication
- Limit outpost exposure - only expose necessary ports
- Review policies regularly to ensure proper access control
- Test recovery procedures before you need them
Backup and Recovery
Backup
Critical data to backup:
- PostgreSQL database:
/opt/configs/authentik/database - Media files:
/opt/configs/authentik/media - Environment file:
.env(contains secrets!)
Backup command:
# Stop containers
docker compose down
# Create backup
tar -czf authentik-backup-$(date +%Y%m%d).tar.gz /opt/configs/authentik .env
# Restart
docker compose up -d
Recovery
# Stop existing installation
docker compose down
# Restore data
tar -xzf authentik-backup-YYYYMMDD.tar.gz -C /
# Start containers
docker compose up -d
Dockge
Dockge on Truenas
Dockge on VPS
Linking Dockge instances
Private Docker Registry
A self-hosted Docker registry with a web UI, running as a Dockge stack on TrueNAS (or any Docker host). Useful for storing images built locally — like the SSH container — so they can be reused without rebuilding from scratch.
Overview
This stack runs two containers:
- registry — the official Docker registry, with htpasswd authentication and delete support enabled
- registry-ui — a lightweight web UI (joxit/docker-registry-ui) for browsing and managing images
The registry is exposed on port 7000 (mapped from the internal 5000) and the UI
on port 7001.
Prerequisites
- Dockge running on your Docker host
htpasswdavailable on the host (part ofapache2-utilson Ubuntu/Debian, orhttpd-toolson RHEL/Fedora)- A data path for persistent storage (e.g.
/mnt/your-pool/data)
Step 1: Create the Auth Directory and Credentials
The registry uses htpasswd for basic authentication. Create the auth file before starting the stack, otherwise the registry will fail to start.
On your TrueNAS shell (or Docker host):
mkdir -p /mnt/your-pool/data/docker-registry/auth
htpasswd -Bc /mnt/your-pool/data/docker-registry/auth/htpasswd <your-username>
You’ll be prompted to set a password. The -B flag uses bcrypt hashing (recommended).
Note: The data directory for registry image layers will be created automatically at
/mnt/your-pool/data/docker-registry/datawhen the container first starts.
Step 2: Deploy with Dockge
In Dockge, create a new stack called docker-registry and paste the following:
services:
registry:
image: registry:2
container_name: docker-registry
restart: unless-stopped
ports:
- 7000:5000
volumes:
- ${DATA_PATH}/docker-registry/data:/var/lib/registry
- ${DATA_PATH}/docker-registry/auth:/auth
environment:
REGISTRY_STORAGE_DELETE_ENABLED: "true"
REGISTRY_AUTH: htpasswd
REGISTRY_AUTH_HTPASSWD_REALM: Registry
REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd
labels:
homepage.group: Dev
homepage.name: Docker Registry
homepage.icon: docker.png
homepage.description: Private container registry
registry-ui:
image: joxit/docker-registry-ui:latest
container_name: registry-ui
restart: unless-stopped
ports:
- 7001:80
environment:
SINGLE_REGISTRY: "true"
REGISTRY_TITLE: Homelab Registry
DELETE_IMAGES: "true"
SHOW_CONTENT_DIGEST: "true"
NGINX_PROXY_PASS_URL: http://registry:5000
depends_on:
- registry
labels:
homepage.group: Dev
homepage.name: Registry Browser
homepage.icon: docker.png
homepage.href: http://<truenas-ip>:7001
homepage.description: Browse container images
networks: {}
Set the environment variable
In Dockge’s env section (or a .env file), set:
DATA_PATH=/mnt/your-pool/data
Deploy the stack. Both containers should come up within a few seconds.
Step 3: Log In from Your Laptop
Before pushing or pulling images, authenticate Docker against the registry:
docker login <truenas-ip>:7000
Enter the username and password you set in Step 1.
Step 4: Push and Pull Images
Tag and push a local image
docker tag my-image:latest <truenas-ip>:7000/my-image:latest
docker push <truenas-ip>:7000/my-image:latest
Pull an image
docker pull <truenas-ip>:7000/my-image:latest
Use in a Compose file
image: <truenas-ip>:7000/my-image:latest
Step 5: Browse the Registry UI
Open http://<truenas-ip>:7001 in your browser. You can browse repositories,
inspect image tags and digests, and delete images from the UI.
Note: The UI connects to the registry through Docker’s internal network (
http://registry:5000), so it does not require the registry to be exposed externally.
Troubleshooting
Registry returns 401 Unauthorized
The htpasswd file may be missing or in the wrong location. Verify:
ls -la /mnt/your-pool/data/docker-registry/auth/htpasswd
Re-run the htpasswd command from Step 1 if needed, then restart the stack.
docker login fails with “http: server gave HTTP response to HTTPS client”
Docker requires HTTPS for registries unless the address is marked as insecure. Add the registry to your Docker daemon’s insecure registries list on any machine that needs to push/pull:
{
"insecure-registries": ["<truenas-ip>:7000"]
}
On Linux this goes in /etc/docker/daemon.json. Restart the Docker daemon after
saving:
sudo systemctl restart docker
On Docker Desktop (Mac/Windows), add it via Settings → Docker Engine.
UI shows “No repositories found” despite images being present
Confirm the NGINX_PROXY_PASS_URL in the UI service points to http://registry:5000
(the internal service name), not the external host port.
minecraft
Minecraft Server Configuration
Server Type
Using itzg/minecraft-server:java21 via Docker Compose (Dockge), running on TrueNAS.
Docker Compose
services:
minecraft:
image: itzg/minecraft-server:java21
container_name: minecraft
restart: unless-stopped
stdin_open: true
tty: true
user: 568:568
labels:
- homepage.group=Gaming
- homepage.name=Minecraft
- homepage.icon=minecraft.png
- homepage.href=https://play.svb-avh.com
- homepage.description=Ailin-1 - Survival Server
ports:
- 25565:25565
- 25575:25575
volumes:
- /mnt/tnt-hex/configs/minecraft:/data
environment:
# Core
EULA: "true"
TYPE: PAPER
VERSION: LATEST
TZ: Europe/Brussels
# Server identity
SERVER_NAME: Ailin-1
MOTD: Welcome to Minecraft on TrueNAS!
SEED: "-2478968794318969857"
# Gameplay
GAMEMODE: survival
DIFFICULTY: peaceful
LEVEL_TYPE: DEFAULT
HARDCORE: "false"
PVP: "false"
ALLOW_FLIGHT: "false"
ONLINE_MODE: "false"
# World
MAX_WORLD_SIZE: "10000"
MAX_BUILD_HEIGHT: "256"
GENERATE_STRUCTURES: "true"
ALLOW_NETHER: "true"
# Players & spawning
MAX_PLAYERS: "20"
SPAWN_PROTECTION: "16"
SPAWN_ANIMALS: "true"
SPAWN_NPCS: "true"
SPAWN_MONSTERS: "true"
FORCE_GAMEMODE: "false"
ANNOUNCE_PLAYER_ACHIEVEMENTS: "true"
ENABLE_COMMAND_BLOCK: "false"
# Performance
VIEW_DISTANCE: "16"
MAX_TICK_TIME: "60000"
# RCON
RCON_ENABLED: "true"
RCON_PASSWORD: ${RCON_PASSWORD}
RCON_PORT: "25575"
# Plugins
PLUGINS: ${PLUGINS}
networks: {}
Secrets (RCON_PASSWORD, PLUGINS) are stored in a .env file alongside the compose file in Dockge.
RCON
RCON is enabled on host port 25575. Use any RCON client (e.g. mcrcon) to run admin commands without joining the server:
mcrcon -H <truenas-ip> -P 25575 -p <RCON_PASSWORD> "whitelist list"
AuthMe Plugin
Purpose: Password protection for offline-mode server
Player Registration (first time):
/register password password
Player Login (subsequent times):
/login password
Admin Commands:
# Reset player password
/authme password PlayerName newpassword
# Unregister player
/authme unregister PlayerName
# Check if player is registered
/authme accounts PlayerName
Access Control
- Whitelist: Only approved usernames can connect
- AuthMe: Password required for each username
- Monitoring: Check logs and use CoreProtect for tracking
Minecraft Server Setup - Pre-registration Only Workflow
Overview
This guide will help you configure your Minecraft server so that:
- Only you can create accounts (pre-registration)
- Players cannot self-register
- You have full control over who plays
- Simple workflow for adding new players
Perfect for managing a kids’ Minecraft server with friends aged 6-8 years old.
Prerequisites
- ✅ Minecraft server running (Paper/Spigot)
- ✅ AuthMe plugin installed
- ✅ You have OP permissions on the server
- ✅ Whitelist enabled
Step 1: Configure Server Properties
Edit server.properties
Location: /path/to/minecraft/server.properties
Ensure these settings:
# Enable whitelist
white-list=true
# Only you can use admin commands
op-permission-level=4
# Disable command blocks (safety)
enable-command-block=false
# Basic security
spawn-protection=16
allow-flight=false
Save and restart the server for changes to take effect.
Step 2: Make Yourself Server Operator
From Server Console
op YourUsername
Verify
Check the ops.json file:
cat /path/to/minecraft/ops.json
Should contain:
[
{
"uuid": "your-uuid",
"name": "YourUsername",
"level": 4,
"bypassesPlayerLimit": false
}
]
Important: Only you should be in this file. Never add kids as operators!
Step 3: Configure AuthMe to Disable Self-Registration
Edit AuthMe Config
Location: /path/to/minecraft/plugins/AuthMe/config.yml
Find and modify these sections:
settings:
# DISABLE self-registration
registration:
enabled: false # Players cannot use /register
force: false # Don't force registration
# Session settings - keep players logged in
sessions:
enabled: true
timeout: 60 # Stay logged in for 60 minutes
# Security settings
security:
minPasswordLength: 4 # Allow short passwords for kids
maxLoginPerIp: 5 # Multiple kids from same house
maxJoinPerIp: 3 # Limit connections per IP
# Protect inventory before login
ProtectInventoryBeforeLogIn: true
# Commands allowed before login (ONLY login, not register)
allowCommands:
- /login
- /help
- /l # Common alias for /login
# Disable external registration methods
ExternalBoardOptions:
enableExternalRegistration: false
restrictions:
# Allowed characters in usernames
allowedNicknameCharacters: '[a-zA-Z0-9_]*'
minNicknameLength: 3
maxNicknameLength: 16
# Allowed commands BEFORE login (no /register here)
allowCommands:
- /login
- /l
- /help
Save and Reload
In-game as OP or from console:
authme reload
Or restart the server:
# Stop server
stop
# Start server (method depends on your setup)
Step 4: Enable Whitelist
From Console or In-Game (as OP)
# Enable whitelist
whitelist on
# Verify it's enabled
whitelist list
Whitelist should now be active. Only usernames you explicitly add can join.
Step 5: Test That Self-Registration is Disabled
Connect as a Non-OP Player
-
Join the server with a test account (or ask someone to test)
-
Try to register:
/register testpass testpass -
Expected result:
[AuthMe] Registration is disabled.or
[AuthMe] Unknown command. Type /help for help.
✅ If you see this, self-registration is successfully disabled!
Step 6: Pre-register Your Kids
Add Kids to Whitelist First
# From console or as OP in-game:
whitelist add Kid1
whitelist add Kid2
Pre-register Each Kid
# From console or as OP in-game:
authme register Kid1 cookie123
authme register Kid2 dino456
Format: authme register <username> <password>
Verify Registration
# Check all registered accounts
authme accounts
# Check specific account
authme accounts Kid1
Should show:
[AuthMe] Player Kid1 is registered.
Step 7: Create a Password Management System
Create a Password List
Create a file to track all accounts (keep this secure!):
File: minecraft-accounts.txt or use a spreadsheet
Username | Password | Real Name | Parent Contact | Date Added
------------|------------|------------------|---------------------|------------
Kid1 | cookie123 | Your First Kid | - | 2025-11-28
Kid2 | dino456 | Your Second Kid | - | 2025-11-28
Friend1 | lego789 | Friend Name | parent@email.com | 2025-11-28
Keep this file secure - it contains all passwords!
Alternative: Use a Password Manager
Store in KeePass, Bitwarden, 1Password, etc. with a note like:
Service: Minecraft Server
Username: Kid1
Password: cookie123
Notes: Real name - Your Kid, Added 2025-11-28
Step 8: Workflow for Adding New Players
When a Parent Asks to Add Their Child
Step 1: Parent Request
- Parent contacts you (in person, text, email)
- Asks: “Can my kid join the Minecraft server?”
- Get kid’s preferred username
Step 2: Pre-register the Account
# Whitelist the username
whitelist add TheirKid
# Register with a simple password
authme register TheirKid simplepass123
Step 3: Share Credentials with Parent
Send parent (securely):
Minecraft Server Details:
- Server Address: mc.mydomain.com:25565
- Username: TheirKid
- Password: simplepass123
Your child will need to type "/login simplepass123" after joining.
Step 4: Record in Your Password List
Add to your tracking file:
TheirKid | simplepass123 | Friend Name | parent@email.com | 2025-11-29
Step 5: Kid Joins!
Kid connects to server and types:
/login simplepass123
Done! ✅
Step 9: Common Management Tasks
Reset a Forgotten Password
# Player forgot password
authme password Kid1 newpassword456
# Tell the kid/parent the new password
Remove a Player
# Unregister from AuthMe
authme unregister BadPlayer
# Remove from whitelist
whitelist remove BadPlayer
# Optional: Ban them
ban BadPlayer Reason for ban
Temporary Kick
# Kick player (they can rejoin)
kick PlayerName Stop being mean to others
Temporary Ban
# Ban for specific time
ban PlayerName 7d Being unkind to others
# Permanent ban
ban PlayerName Griefing is not allowed
View Who’s Online
# See online players
list
# See all whitelisted players
whitelist list
# See all registered accounts
authme accounts
Check Player Info
# Last login time
authme lastlogin Kid1
# All accounts from an IP
authme accounts
Step 10: Create Quick Reference Commands
Admin Quick Reference
Save this somewhere handy:
# ADD NEW PLAYER
whitelist add PlayerName
authme register PlayerName password123
# RESET PASSWORD
authme password PlayerName newpassword
# REMOVE PLAYER
authme unregister PlayerName
whitelist remove PlayerName
# KICK/BAN
kick PlayerName reason
ban PlayerName time reason
pardon PlayerName
# VIEW INFO
whitelist list
authme accounts
list
authme lastlogin PlayerName
# RELOAD CONFIG
authme reload
Step 11: Player Instructions for Parents
Create a simple guide to share with parents:
=== Minecraft Server - Player Guide ===
SERVER ADDRESS: mc.mydomain.com:25565
USERNAME: [provided by admin]
PASSWORD: [provided by admin]
HOW TO JOIN:
1. Open Minecraft (Java Edition)
2. Click "Multiplayer"
3. Click "Add Server"
4. Server Name: [Your Server Name]
5. Server Address: mc.mydomain.com:25565
6. Click "Done"
7. Double-click server to join
AFTER JOINING:
- Type in chat: /login [your password]
- Example: /login cookie123
HELP:
- If you forget your password, contact [your name]
- If you can't connect, contact [your name]
RULES:
- Be kind to others
- No griefing (breaking others' stuff)
- No stealing
- Have fun!
Step 12: Safety & Monitoring
Install CoreProtect (Optional but Recommended)
Tracks who breaks/places blocks:
Add to your server’s plugin configuration:
PLUGINS: |
https://github.com/AuthMe/AuthMeReloaded/releases/download/5.6.0-beta2/AuthMe-5.6.0-beta2.jar
https://github.com/PlayPro/CoreProtect/releases/download/22.4/CoreProtect-22.4.jar
Usage:
# Enable inspector mode
/co inspect
# Right-click blocks to see who placed/broke them
# Rollback a player's actions
/co rollback u:BadPlayer t:30m
# Restore (undo rollback)
/co restore u:Kid1 t:1h
Regular Checks
Daily:
- Check who’s online:
list - Monitor chat for issues
Weekly:
- Review server logs
- Check for unusual activity
- Make backups
Monthly:
- Review whitelist (remove inactive players)
- Update plugins
- Check disk space
Troubleshooting
Player Says “Registration is disabled”
✅ This is correct! Tell them:
- They cannot register themselves
- They need to contact you for an account
- You will pre-register them
Player Forgot Password
# Reset it
authme password PlayerName newpassword123
# Tell them the new password
Player Can’t Join Server
Check:
- Are they whitelisted?
whitelist list - Are they registered?
authme accounts PlayerName - Is server online?
list - Firewall/network issue?
Fix:
# Add to whitelist
whitelist add PlayerName
# Pre-register if needed
authme register PlayerName password
“Too many login attempts”
Player entered wrong password too many times.
Wait 5-10 minutes, or:
# Unregister and re-register
authme unregister PlayerName
authme register PlayerName newpassword
Player’s Inventory Missing After Login
This is an AuthMe feature for security.
To disable (not recommended): Edit config.yml:
ProtectInventoryBeforeLogIn: false
Security Checklist
Before opening server to kids:
- Whitelist enabled (
white-list=true) - Only you are OP (check
ops.json) - AuthMe registration disabled (
registration: enabled: false) - Self-registration tested and blocked
- Test account created and working
- Password list created and secured
- Parent instructions prepared
- Server rules written
- Backup system in place
- Monitoring tools configured (optional)
Complete Configuration Reference
server.properties
white-list=true
op-permission-level=4
enable-command-block=false
spawn-protection=16
allow-flight=false
online-mode=false
AuthMe config.yml (Key Sections)
settings:
registration:
enabled: false
force: false
sessions:
enabled: true
timeout: 60
security:
minPasswordLength: 4
maxLoginPerIp: 5
maxJoinPerIp: 3
ProtectInventoryBeforeLogIn: true
allowCommands:
- /login
- /help
- /l
Recommended Workflow Summary
- Parent requests access for their kid
- You pre-register:
whitelist add Kid && authme register Kid pass - Share credentials with parent (username + password + server address)
- Kid joins and logs in:
/login password - Record in password list for future reference
- Monitor and moderate as needed
Benefits of This Approach
✅ Full control - You approve every account
✅ No unwanted players - Only whitelisted can join
✅ Simple for kids - Just need to remember /login password
✅ Easy password recovery - You can reset anytime
✅ Parent involvement - Parents request access
✅ Safe environment - No random players
✅ No OP accidents - Kids can’t become admin
Next Steps
- ✅ Complete Steps 1-6 above
- ✅ Test with a dummy account
- ✅ Pre-register your own kids
- ✅ Create parent instructions document
- ✅ Share server info with trusted parents
- ✅ Monitor and have fun!
Support & Resources
- AuthMe Documentation: https://github.com/AuthMe/AuthMeReloaded/wiki
- Minecraft Commands: https://minecraft.fandom.com/wiki/Commands
- Server Management: Check your server logs in
/logs/latest.log
Last Updated: November 28, 2025
Forgejo Setup
Self-hosted Git server via Docker, exposed through Pangolin/Traefik with SSH cloning support.
Docker Compose
services:
forgejo:
image: codeberg.org/forgejo/forgejo:15
container_name: forgejo
restart: unless-stopped
environment:
- USER_UID=1000
- USER_GID=1000
ports:
- 7003:3000
- 2020:22
volumes:
- ${CONFIG_PATH}/forgejo:/etc/gitea
- ${DATA_PATH}/forgejo:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
$CONFIG_PATH— storesapp.iniand custom templates$DATA_PATH— stores repositories, LFS, SQLite database, logs
First Run: Install Wizard
Navigate to http://yourhost:7003 and configure:
| Field | Value |
|---|---|
| Database type | SQLite3 (fine for personal use) |
| Repository root path | /data/gitea/repositories |
| Server domain | git.yourdomain.com |
| SSH server port | 2020 (external/mapped port, used for clone URLs) |
| HTTP listen port | 3000 (internal container port) |
| Base URL | https://git.yourdomain.com/ |
Expand Administrator account settings and create your admin user before clicking Install. Otherwise the first person to register becomes admin.
The config is saved to /data/gitea/conf/app.ini.
Pangolin / Traefik Exposure
Forgejo is exposed via Pangolin running on a Hetzner VPS. HTTP goes through Pangolin’s normal tunnel. SSH requires additional Traefik TCP routing.
1. Expose port 2020 on the Gerbil container
In /opt/docker/pangolin/docker-compose.yml, add port 2020 to the gerbil service:
gerbil:
...
ports:
- 51820:51820/udp
- 21820:21820/udp
- 443:443
- 80:80
- 2020:2020/tcp # <-- add this
2. Add the entrypoint to Traefik static config
In /opt/docker/pangolin/config/traefik/traefik_config.yml, add under entryPoints:
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
...
forgejo-ssh:
address: ":2020"
3. Add the TCP router to Traefik dynamic config
In /opt/docker/pangolin/config/traefik/dynamic_config.yml, add to the existing tcp section:
tcp:
routers:
forgejo-ssh:
rule: "HostSNI(`*`)"
entryPoints:
- forgejo-ssh
service: forgejo-ssh-service
services:
forgejo-ssh-service:
loadBalancer:
servers:
- address: "192.168.50.203:2020" # homelab LAN IP, reachable via WireGuard tunnel
Note: Traefik runs with
network_mode: service:gerbil, so ports must be declared on thegerbilcontainer, not Traefik directly. The homelab is reachable from the VPS via the WireGuard peer covering192.168.50.0/24.
4. Allow port 2020 through UFW on the VPS
ufw allow 2020/tcp
5. Restart the Pangolin stack
cd /opt/docker/pangolin
docker compose down && docker compose up -d
Use
down && uprather thanrestart— static Traefik config changes (entrypoints) require a full restart to take effect.
Verify SSH
Test SSH connectivity:
ssh -T -p 2020 git@git.yourdomain.com
# Expected: Hi <username>! You've successfully authenticated...
Clone a repo:
git clone ssh://git@git.yourdomain.com:2020/user/repo.git
Troubleshooting
| Symptom | Likely cause |
|---|---|
| HTTPS clone redirects to Pangolin login | Set the Forgejo resource to public in Pangolin — Forgejo handles its own auth |
| SSH hangs | Port not open in UFW or not exposed on Gerbil |
| SSH connection refused | Traefik not listening — check entrypoint in static config, restart with down && up |
| SSH connection reset | Wrong backend address in TCP service, or WireGuard tunnel not routing the port |
manifest unknown on pull | Tag doesn’t exist — use 15 not latest |
Wolf (Games on Whales) on tnt-hex
Retro game streaming from TrueNAS SCALE to Moonlight clients, sharing the RTX 4060 Ti with existing AI containers.
As built, July 2026. Server 192.168.50.203, driver 570.172.08.
0. How Wolf works
Wolf is one long-lived container. It does not contain the emulators. When a Moonlight client picks an app, Wolf talks to /var/run/docker.sock and spins up a sibling container on the fly, streams its virtual Wayland desktop, and tears it down on disconnect.
Consequences worth internalising:
- Wolf needs the Docker socket, which is root-equivalent on tnt-hex.
- App containers appear in the TrueNAS UI, come and go, and carry a session UUID in their name. Anything not written through a declared mount is lost when the session ends.
- Paths in Wolf’s config are resolved to host paths, because Wolf passes them to the Docker API.
1. Layout
/mnt/tnt-hex/configs/wolf host-init.sh, udev rules
/mnt/tnt-hex/data/wolf Wolf state: config.toml, certs, per-app home dirs
/mnt/tnt-hex/data/retroarch libretro cores
/mnt/tnt-hex/media/roms RomM library, <platform>/roms/
configs is small hand-edited text. data/wolf grows: under profile-data/ it accumulates each app’s /home/retro, so emulator saves, states, and scraped media. Its own dataset is worth it.
data/wolf/cfg/ holds Wolf’s certificate and paired-client list. That is the directory to back up if you do not want to re-pair every client.
Permissions
App containers run as UID/GID 1000:1000, while the media dataset is owned by apps (568). The library shipped as drwxrw-r--: readable but not traversable by 1000.
The symptom is easy to misread, because ES-DE lists every filename correctly and then fails on all of them:
Error: FileSystemUtil::isDirectory(): filesystem error: status: Permission denied [/ROMs/gbc/...]
Read on a directory returns names; execute is what lets you look up the inodes behind them.
sudo chmod -R a+rX /mnt/tnt-hex/media/roms
Capital X adds execute to directories only, so ROM files do not become executable, and + only adds, so RomM keeps its access. This works because the dataset is acltype=nfsv4 with aclmode=passthrough, so chmod writes through to the ACL. With restricted or discard you would need the TrueNAS ACL editor instead.
Verify as the container user, not as root, since root reads straight through:
ESDE=$(sudo docker ps --format '{{.Names}}' | grep '^WolfES-DE')
sudo docker exec -u 1000 "$ESDE" stat /ROMs/gbc | head -4
The profile directory needs ownership rather than permissions:
sudo chown -R 1000:1000 /mnt/tnt-hex/data/wolf /mnt/tnt-hex/data/retroarch
2. Host prep
2.1 Render node
ls -l /sys/class/drm/renderD*/device/driver
tnt-hex returns a single node, renderD128 -> .../drivers/nvidia. With one GPU, WOLF_RENDER_NODE can stay at its default. With two, set it explicitly to the NVIDIA node, since Wolf’s zero-copy pipeline needs the Wayland rendering and the GStreamer encoding on the same device.
2.2 DRM modeset
Wolf needs this on NVIDIA for the headless compositor:
sudo cat /sys/module/nvidia_drm/parameters/modeset
It was N. Do not edit /etc/default/grub, which does not survive TrueNAS upgrades. The middleware stores the value in the config database and reapplies it on every boot:
sudo midclt call system.advanced.config | jq -r .kernel_extra_options # check first, this replaces
sudo midclt call system.advanced.update '{"kernel_extra_options": "nvidia-drm.modeset=1"}'
Reboot, confirm it reads Y. Modeset affects the DRM/KMS interface only, not CUDA, so existing AI containers are unaffected.
2.3 udev rules
/mnt/tnt-hex/configs/wolf/85-wolf-virtual-inputs.rules:
# Allows Wolf to access /dev/uinput (joypad support)
KERNEL=="uinput", SUBSYSTEM=="misc", MODE="0660", GROUP="input", OPTIONS+="static_node=uinput", TAG+="uaccess"
# Allows Wolf to access /dev/uhid (DualSense emulation)
KERNEL=="uhid", GROUP="input", MODE="0660", TAG+="uaccess"
# Keep the host's logind from claiming the virtual pads
KERNEL=="hidraw*", ATTRS{name}=="Wolf PS5 (virtual) pad", GROUP="root", MODE="0660", ENV{ID_SEAT}="seat9"
SUBSYSTEMS=="input", ATTRS{name}=="Wolf X-Box One (virtual) pad", GROUP="root", MODE="0660", ENV{ID_SEAT}="seat9"
SUBSYSTEMS=="input", ATTRS{name}=="Wolf PS5 (virtual) pad", GROUP="root", MODE="0660", ENV{ID_SEAT}="seat9"
SUBSYSTEMS=="input", ATTRS{name}=="Wolf gamepad (virtual) motion sensors", GROUP="root", MODE="0660", ENV{ID_SEAT}="seat9"
SUBSYSTEMS=="input", ATTRS{name}=="Wolf Nintendo (virtual) pad", GROUP="root", MODE="0660", ENV{ID_SEAT}="seat9"
2.4 Post Init script
TrueNAS rebuilds /etc on upgrade and NVIDIA device nodes are created lazily, so both get reapplied at boot. See host-init.sh alongside this document.
chmod +x /mnt/tnt-hex/configs/wolf/host-init.sh
sudo /mnt/tnt-hex/configs/wolf/host-init.sh
Register under System Settings > Advanced > Init/Shutdown Scripts > Add:
- Description:
Wolf: load uinput/uhid modules + install virtual gamepad udev rules - Type:
Script, Script:/mnt/tnt-hex/configs/wolf/host-init.sh - When:
Post Init, Timeout:30
The nvidia-modprobe step matters more than it looks. Device nodes are normally created by the first GPU container to start, but Wolf’s compose lists them explicitly and Docker refuses to create a container referencing a node that does not exist yet.
Check it survived a reboot with journalctl -t wolf-init.
3. NVIDIA driver volume
Upstream recommends this over the container toolkit, because Wolf injects drivers into the child containers it spawns and --gpus=all is unreliable there.
mkdir -p /tmp/gow-build && cd /tmp/gow-build
curl -fsSL https://raw.githubusercontent.com/games-on-whales/gow/master/images/nvidia-driver/Dockerfile \
| sudo docker build -t gow/nvidia-driver:latest -f - \
--build-arg NV_VERSION=$(cat /sys/module/nvidia/version) .
sudo docker create --rm --mount source=nvidia-driver-vol,destination=/usr/nvidia \
gow/nvidia-driver:latest sh
sudo docker run --rm -v nvidia-driver-vol:/usr/nvidia alpine ls /usr/nvidia/lib | head
The container is never started; Docker seeds a new named volume from the image at creation time. That last command is the check that matters, since an empty volume fails silently at encode time rather than at deploy. You want libnvidia-*.so.570.172.08.
This is the recurring maintenance item. After any TrueNAS driver bump: docker volume rm nvidia-driver-vol, then redo both commands. The symptom otherwise is an opaque driver/library version mismatch.
4. The Dockge stack
.env
DATA_PATH=/mnt/tnt-hex/data
WOLF_HTTP_PORT=8005
compose.yaml
services:
wolf:
image: ghcr.io/games-on-whales/wolf:stable
container_name: wolf
restart: unless-stopped
network_mode: host
environment:
- NVIDIA_DRIVER_VOLUME_NAME=nvidia-driver-vol
# Also sets where Wolf looks for config INSIDE the container, so the bind
# below must use the same path on both sides.
- HOST_APPS_STATE_FOLDER=${DATA_PATH}/wolf
- WOLF_HTTP_PORT=${WOLF_HTTP_PORT}
volumes:
# Identical on both sides, deliberately. Wolf inspects its own container's
# mounts to translate container paths into host paths when spawning app
# containers. A destination of /etc/wolf with a different source breaks that.
- ${DATA_PATH}/wolf:${DATA_PATH}/wolf:rw
- /var/run/docker.sock:/var/run/docker.sock:rw
- /dev/:/dev/:rw
- /run/udev:/run/udev:rw
- nvidia-driver-vol:/usr/nvidia:rw
devices:
- /dev/dri
- /dev/uinput
- /dev/uhid
- /dev/nvidia-uvm
- /dev/nvidia-uvm-tools
- /dev/nvidiactl
- /dev/nvidia0
- /dev/nvidia-modeset
- /dev/nvidia-caps/nvidia-cap1
- /dev/nvidia-caps/nvidia-cap2
device_cgroup_rules:
- 'c 13:* rmw'
labels:
- homepage.group=Games
- homepage.icon=moonlight
- homepage.name=Wolf
- homepage.description=Game streaming host
- homepage.href=http://192.168.50.203:8005
- diun.enable=true
volumes:
nvidia-driver-vol:
external: true
Devices, volumes, and device_cgroup_rules match the upstream NVIDIA example exactly. WOLF_STOP_CONTAINER_ON_EXIT, WOLF_LOG_LEVEL, and WOLF_RENDER_NODE are all left at their defaults; set WOLF_LOG_LEVEL=DEBUG temporarily when diagnosing a launch failure.
Ports. Wolf supports six overrides: WOLF_HTTP_PORT (47989), WOLF_HTTPS_PORT (47984), WOLF_CONTROL_PORT (47999), WOLF_RTSP_SETUP_PORT (48010), WOLF_VIDEO_PING_PORT (48100), WOLF_AUDIO_PING_PORT (48200). Only HTTP is moved, since it is the one reached from a browser. The rest are negotiated during the session and renumbering them buys nothing.
Healthy startup log
INFO | Using h264 encoder: nvcodec
INFO | Using h265 encoder: nvcodec
INFO | Using av1 encoder: nvcodec
INFO | Using zero copy pipeline on Nvidia (/dev/dri/renderD128)
INFO | HTTP server listening on port: 8005
INFO | HTTPS server listening on port: 47984
Ignorable warnings: vaInitialize: unknown libva error and Cannot create a VA display (GStreamer probing VA-API and QuickSync, irrelevant on NVIDIA); libgstvalidatessim.so failed to initialise; nvrtcGetCUBINSize: undefined symbol (runtime CUDA compilation, not needed by NVENC); Unable to become drm master (expected unprivileged mode); GStreamer queue and latency warnings. Adding user 'root' to groups: gow-gid-105 is a good sign, that is the input group.
Errors that matter: Unable to find docker mount for path means the bind source and destination do not match. mDNS error: Failed to open any client sockets means UDP 5353 is already held by TrueNAS, so auto-discovery will not work.
Confirm state landed on the pool rather than in the container’s writable layer:
ls -l /mnt/tnt-hex/data/wolf/cfg/ # want config.toml, cert.pem, key.pem
5. Pairing
mDNS is unavailable on this host, so add the host manually as 192.168.50.203:8005 in Moonlight.
Wolf logs a URL like http://192.168.50.203:8005/pin/#ABCD1234. Open it, enter the PIN. Moonlight then lists apps, with Wolf UI as the default entry point.
First launch of any app shows a black screen with a cursor while Wolf pulls a multi-gigabyte image. Watch the Wolf logs, not the stream.
6. ES-DE
Wolf writes /mnt/tnt-hex/data/wolf/cfg/config.toml on first start, ships an ES-DE entry with mounts = [], and that is why it initially shows nothing but Custom Scripts. ES-DE only displays systems containing at least one ROM file.
Two rules govern every edit here:
- The file is read only at startup, and Wolf rewrites it from memory on shutdown. Stop the stack before editing or your changes get clobbered.
- App containers are built at launch time. After restarting Wolf you must also quit and relaunch the app from Moonlight.
6.1 Mounts
ES-DE’s ROM directory is /ROMs, absolute, set in the image’s baked-in es_settings.xml. Not /home/retro/ROMs.
The library nests games at <platform>/roms/ and several folder names do not match ES-DE’s system names, so one bind per system does both jobs at once, with the destination doing the renaming.
Find the block whose runner name is WolfES-DE and set its mounts:
mounts = [
'/mnt/tnt-hex/media/roms/snes/roms:/ROMs/snes:ro',
'/mnt/tnt-hex/media/roms/gba/roms:/ROMs/gba:ro',
'/mnt/tnt-hex/media/roms/nes/roms:/ROMs/nes:ro',
'/mnt/tnt-hex/media/roms/segaMD/roms:/ROMs/megadrive:ro',
'/mnt/tnt-hex/media/roms/gb/roms:/ROMs/gb:ro',
'/mnt/tnt-hex/media/roms/gbc/roms:/ROMs/gbc:ro',
'/mnt/tnt-hex/media/roms/atari2600/roms:/ROMs/atari2600:ro',
'/mnt/tnt-hex/media/roms/n64/roms:/ROMs/n64:ro',
'/mnt/tnt-hex/media/roms/segaMS/roms:/ROMs/mastersystem:ro',
'/mnt/tnt-hex/media/roms/segaGG/roms:/ROMs/gamegear:ro',
'/mnt/tnt-hex/media/roms/colecovision/roms:/ROMs/colecovision:ro',
'/mnt/tnt-hex/media/roms/segaSG/roms:/ROMs/sg-1000:ro',
'/mnt/tnt-hex/media/roms/atari5200/roms:/ROMs/atari5200:ro',
'/mnt/tnt-hex/media/roms/atari7800/roms:/ROMs/atari7800:ro',
'/mnt/tnt-hex/media/roms/sega32x/roms:/ROMs/sega32x:ro',
'/mnt/tnt-hex/media/roms/psx/roms:/ROMs/psx:ro',
'/mnt/tnt-hex/media/roms/vb/roms:/ROMs/virtualboy:ro',
'/mnt/tnt-hex/media/roms/ngc/roms:/ROMs/gc:ro',
'/mnt/tnt-hex/media/roms/wii/roms:/ROMs/wii:ro',
'/mnt/tnt-hex/media/roms/nds/roms:/ROMs/nds:ro',
'/mnt/tnt-hex/media/roms/psp/roms:/ROMs/psp:ro',
'/mnt/tnt-hex/media/roms/ps2/roms:/ROMs/ps2:ro',
'/mnt/tnt-hex/data/retroarch/cores:/home/retro/.config/retroarch/cores:rw',
]
- Read-only for ROMs. RomM owns that tree, ES-DE has a delete-game function, and emulators drop
.srmand.statefiles next to the ROM. Saves belong in/home/retro. - The cores mount must be
rw. Anything inside/home/retro/.configor/home/retro/ES-DEis subject to the init script’schown -R, and a read-only mount there makes the container fail at boot. - Only populated systems listed. The library’s other folders are empty.
- Single quotes match what Wolf writes back. TOML literal strings, so nothing needs escaping.
- ES-DE 3.x system names catch people out: GameCube is
gc, PC Engine istg16, Master System ismastersystem, SG-1000 issg-1000.
Verify after relaunching:
ESDE=$(sudo docker ps --format '{{.Names}}' | grep '^WolfES-DE')
sudo docker exec "$ESDE" ls /ROMs/
6.2 Cores
The es-de image ships RetroArch with no cores. This is by design; the upstream pattern is mounting them from the host, which needs a host RetroArch that TrueNAS does not have.
sudo mkdir -p /mnt/tnt-hex/data/retroarch/cores
cd /tmp
BB=https://buildbot.libretro.com/nightly/linux/x86_64/latest
for c in snes9x nestopia gambatte mgba genesis_plus_gx picodrive \
mupen64plus_next stella prosystem gearcoleco swanstation dolphin; do
curl -fsSL "$BB/${c}_libretro.so.zip" -o "$c.zip" \
&& sudo python3 -c "import zipfile;zipfile.ZipFile('$c.zip').extractall('/mnt/tnt-hex/data/retroarch/cores')" \
&& echo "ok: $c"
done
sudo chown -R 1000:1000 /mnt/tnt-hex/data/retroarch
Those twelve cover every populated system. ES-DE picks up new cores at launch time, so no restart is needed for additions.
If a launch fails with emulator core file "X_libretro.so" not found, fetch that specific core, or set an alternative in ES-DE: highlight the system, Options, Alternative Emulators.
Diagnosis lives in ES-DE’s own log, not Wolf’s. Wolf only sees the session lifecycle:
sudo docker exec "$ESDE" grep -A6 "Couldn't launch\|Launching game" \
/home/retro/ES-DE/logs/es_log.txt | tail -30
6.3 Keyboard layout
Add to the app’s env array:
'XKB_DEFAULT_LAYOUT=be'
7. Clients
moonlight-qt is in Arch’s extra repo:
sudo pacman -S moonlight-qt libva-utils
vainfo # want VAProfileH264* and VAProfileHEVCMain from radeonsi
Avoid moonlight-qt-bin from the AUR, which repackages the AppImage and breaks hardware decode by linking against an older VA-API than Arch ships. The Flathub build is upstream’s own channel and bundles its own ffmpeg, which is a fair argument on a rolling distro; the repo package is currently at parity.
Add the host manually as 192.168.50.203:8005.
Settings: HEVC on (the 4060 Ti’s NVENC handles it and halves bitrate for the same quality), 60fps, 20-30 Mbps wired. Leave “Optimize game settings” off, it is a GameStream-era feature Wolf does not use.
Moonlight captures the keyboard while streaming. Ctrl+Alt+Shift+Q quits, Ctrl+Alt+Shift+Z toggles mouse capture.
8. Maintenance and gotchas
| Thing | What happens | Fix |
|---|---|---|
| NVIDIA driver bump | opaque driver/library version mismatch | rebuild nvidia-driver-vol (section 3) |
| TrueNAS upgrade | /etc/udev/rules.d wiped, gamepads stop working | Post Init script reapplies it; kernel_extra_options survives on its own |
| Config edits vanish | Wolf rewrites config.toml on shutdown | stop the stack before editing |
| App keeps old mounts | app containers are built at launch time | quit and relaunch the app, restarting Wolf is not enough |
| ES-DE lists games, launches fail | ROM dirs r without x for UID 1000 | chmod -R a+rX on the library |
| Nothing launches, no cores | image ships RetroArch without cores | section 6.2 |
| App container fails at boot | :ro mount inside /home/retro/.config blocks chown -R | make it :rw |
| Moonlight finds nothing | UDP 5353 held by TrueNAS | add host manually with the port |
ix-apps fills up | image pulls fail | check space before adding large apps |
Snapshot the working state:
sudo zfs snapshot tnt-hex/data@wolf-working
Still untested: whether virtual gamepads reach the emulator end to end, and whether the Post Init script fires unattended on a cold boot.
References
- Wolf quickstart: https://games-on-whales.github.io/wolf/stable/user/quickstart.html
- Wolf configuration: https://games-on-whales.github.io/wolf/stable/user/configuration.html
- App images (Wildlife): https://games-on-whales.github.io/wildlife/
Each Wildlife app page carries a Giscus comment thread below the default config block, and that is where the operational detail lives: real mount layouts, which paths need rw, where scraped media goes. The page bodies are just a screenshot and the stock config.toml. Comments do not appear in fetched or archived copies, so read them in a browser.
Paperless-ngx Setup
Self-hosted document management with OCR, running locally via Dockge, reachable at paperless.home.lan through NPM. Not exposed externally.
Docker Compose
Paperless needs Postgres, Redis, and (for Office doc conversion) Gotenberg + Tika alongside the webserver, so it’s kept as one self-contained stack.
services:
paperless-db:
image: postgres:18
container_name: paperless-db
restart: unless-stopped
volumes:
- ${DATA_PATH}/paperless/db:/var/lib/postgresql
environment:
- POSTGRES_DB=${PAPERLESS_DB_NAME}
- POSTGRES_USER=${PAPERLESS_DB_USER}
- POSTGRES_PASSWORD=${PAPERLESS_DB_PASSWORD}
- TZ=Europe/Brussels
paperless-redis:
image: docker.io/library/redis:7
container_name: paperless-redis
restart: unless-stopped
volumes:
- ${DATA_PATH}/paperless/redis:/data
paperless-gotenberg:
image: docker.io/gotenberg/gotenberg:8.34
container_name: paperless-gotenberg
restart: unless-stopped
command:
- "gotenberg"
- "--chromium-disable-javascript=true"
- "--chromium-allow-list=file:///tmp/.*"
paperless-tika:
image: docker.io/apache/tika:3.3.1.0
container_name: paperless-tika
restart: unless-stopped
paperless-webserver:
image: ghcr.io/paperless-ngx/paperless-ngx:latest
container_name: paperless-webserver
restart: unless-stopped
depends_on:
- paperless-db
- paperless-redis
- paperless-gotenberg
- paperless-tika
ports:
- 8504:8000
volumes:
- ${DATA_PATH}/paperless/data:/usr/src/paperless/data
- ${DATA_PATH}/paperless/media:/usr/src/paperless/media
- ${DATA_PATH}/paperless/export:/usr/src/paperless/export
- ${DATA_PATH}/paperless/consume:/usr/src/paperless/consume
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
environment:
- USERMAP_UID=950
- USERMAP_GID=950
- PAPERLESS_TIME_ZONE=Europe/Brussels
- PAPERLESS_REDIS=redis://paperless-redis:6379
- PAPERLESS_DBHOST=paperless-db
- PAPERLESS_DBNAME=${PAPERLESS_DB_NAME}
- PAPERLESS_DBUSER=${PAPERLESS_DB_USER}
- PAPERLESS_DBPASS=${PAPERLESS_DB_PASSWORD}
- PAPERLESS_OCR_LANGUAGE=nld+eng
- PAPERLESS_OCR_LANGUAGES=nld
- PAPERLESS_TIKA_ENABLED=1
- PAPERLESS_TIKA_GOTENBERG_ENDPOINT=http://paperless-gotenberg:3000
- PAPERLESS_TIKA_ENDPOINT=http://paperless-tika:9998
- PAPERLESS_URL=http://paperless.home.lan
- PAPERLESS_SECRET_KEY=${PAPERLESS_SECRET_KEY}
labels:
- homepage.group=Homelab
- homepage.name=Paperless-ngx
- homepage.icon=paperless-ngx.png
- homepage.href=http://192.168.50.203:8504/
- homepage.description=Document management & OCR
Add these to the stack’s .env tab in Dockge, alongside your existing DATA_PATH/CONFIG_PATH:
PAPERLESS_DB_NAME=paperless
PAPERLESS_DB_USER=paperless
PAPERLESS_DB_PASSWORD=<pick a real secret>
PAPERLESS_SECRET_KEY=<generate with the command below>
Generate the secret key on any machine with Python:
python3 -c "import secrets; print(secrets.token_urlsafe(64))"
Paste the output in as PAPERLESS_SECRET_KEY. It just needs to be long and random, not memorable, since it’s used for session tokens.
- Postgres
18— mounted at/var/lib/postgresql, not/var/lib/postgresql/data. Starting with 18, the official image stores data in apg_ctlcluster-style versioned subdirectory under that mount point, so pointing the volume at the old.../datapath leaves it looking at an empty, unused mount and the container exits immediately. This matches paperless-ngx’s own current compose file, not an arbitrary pin. Fine for a fresh install; the migration headaches reported for 16→18 only apply if you’re moving an existing database, not starting new - Redis, not Valkey — upstream’s current template uses
valkey/valkey:9-alpineas the broker now, but Redis still works fine and keeping it matches what the rest of your stack already runs ${DATA_PATH}— from the Dockge.envtab, holds all persistent data (db, media, consume folder, etc.)${PAPERLESS_DB_NAME}/${PAPERLESS_DB_USER}/${PAPERLESS_DB_PASSWORD}— also from the Dockge.envtab, so the real password never sits in the compose file itself; used by bothpaperless-dbandpaperless-webserver, and must stay in sync since it’s the same credential on both sidesPAPERLESS_OCR_LANGUAGE=nld+eng— OCR runs both languages per document;PAPERLESS_OCR_LANGUAGES=nldtriggers the Dutch Tesseract pack to install on first boot, since only a handful of languages ship in the base image- Port
8504— grouped with your other document utilities; double check nothing else already sits there, since the service inventory is only as complete as what’s documented per-file USERMAP_UID/USERMAP_GID— this is Paperless’s own mechanism, distinct from thePUID/PGIDconvention used by LinuxServer images; Postgres and Redis manage their own internal permissions and shouldn’t get a forceduser:directive, same reasoning as the Home Assistant case
First Run
- Bring the stack up in Dockge, wait for
paperless-webserverto report healthy (first boot installs the Dutch OCR pack, so it takes a minute or two longer than usual) - Create the admin user:
docker exec -it paperless-webserver python3 manage.py createsuperuser
- Log in at
http://192.168.50.203:8504
NPM Reverse Proxy (local only)
In NPM, add a proxy host:
| Field | Value |
|---|---|
| Domain | paperless.home.lan |
| Forward host | 192.168.50.203 |
| Forward port | 8504 |
| Websockets support | On |
No SSL needed for .home.lan, unless you’re already terminating TLS locally for other services. Update PAPERLESS_URL in the compose file to match whatever hostname you settle on, since Paperless checks the Origin header against it.
Document Intake
Watch folder (SMB drop): Share ${DATA_PATH}/paperless/consume out via a TrueNAS SMB share (or reuse an existing share and symlink it in). Anything dropped there gets consumed and deleted automatically within a few seconds.
Email import (IMAP): This is configured inside the app, not in compose. Log in, go to Settings → E-mail, add the account, then create a rule (mailbox, filter, and what to do with the mail afterward — mark read, move, delete). Paperless polls it periodically once a rule exists.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| OCR fails or produces gibberish on Dutch documents | PAPERLESS_OCR_LANGUAGES pack didn’t install; check paperless-webserver logs on first boot, or exec in and run apt list --installed | grep tesseract-ocr-nld |
| Login redirects or CSRF errors through NPM | PAPERLESS_URL doesn’t match the hostname you’re browsing to |
| Office docs (docx/xlsx) fail to import | Gotenberg or Tika container isn’t healthy; check both are up before blaming the webserver |
| Consume folder not picking up files | Permission mismatch — files need to be readable by UID 950 (or whatever USERMAP_UID is set to) |
Connections
connections
${template.each(query[[ from p = index.tag “page” where string.find(p.name, editor.getCurrentPage() .. “/”, 1, true) ]], templates.pageItem)}
WireGuard Backhaul (wg-easy)
A bidirectional VPN tunnel between the Hetzner VPS and the TrueNAS homelab, used as a backbone connection. Unlike Pangolin/Olm (which is VPS→homelab only), this allows the homelab to initiate connections to the VPS.
Architecture
TrueNAS (wg-easy server) Hetzner VPS (WireGuard peer)
10.8.0.1 ◄────── WireGuard ──────► 10.8.0.x
192.168.50.0/24 (VPS LAN services)
- Server: wg-easy runs on TrueNAS, reachable via DDNS (
tnt-mephisto.asuscomm.com:51821) - VPN subnet:
10.8.0.0/24 - Allowed routes pushed to peers:
192.168.50.0/24(homelab LAN) +10.8.0.0/24(VPN tunnel)
Prerequisites
- TrueNAS with Docker/Dockge
- Home router with port forwarding capability
- WireGuard installed on the VPS (
apt install wireguard-tools) - DDNS hostname pointing to your home IP
Step 1: Generate Password Hash
wg-easy requires a bcrypt hash for the web UI password. Generate one with Docker before deploying:
docker run --rm ghcr.io/wg-easy/wg-easy wgpw 'YourPasswordHere'
Copy the output — it will look like $2b$12$.... This goes into your .env file.
Step 2: Create .env File
In Dockge, create an .env file alongside the compose file:
PASSWORD_HASH=$$2b$$12$$your_hash_here
Important: Escape every
$in the hash as$$in the.envfile.
Step 3: Deploy wg-easy on TrueNAS
Create a new stack in Dockge with this compose file:
services:
wg-easy:
image: ghcr.io/wg-easy/wg-easy:latest
container_name: wg-easy
network_mode: host
restart: unless-stopped
cap_add:
- NET_ADMIN
- SYS_ADMIN
labels:
- homepage.group=Homelab
- homepage.name=WireGuard
- homepage.icon=wireguard.png
- homepage.href=http://192.168.50.203:51822/
- homepage.description=VPN manager
ports:
- 51821:51821/udp # WireGuard tunnel
- 51822:51822/tcp # Web UI
volumes:
- /mnt/tnt-hex/configs/wireguard:/etc/wireguard
environment:
TZ: Etc/UTC
WG_HOST: tnt-mephisto.asuscomm.com
WG_PORT: "51821"
PORT: "51822"
WG_PATH: /etc/wireguard
WG_DEVICE: enp34s0
WG_MTU: "1420"
WG_DEFAULT_DNS: 1.1.1.1
WG_DEFAULT_ADDRESS: 10.8.0.x
WG_ALLOWED_IPS: |-
192.168.50.0/24
10.8.0.0/24
WG_PERSISTENT_KEEPALIVE: "120"
PASSWORD_HASH: ${PASSWORD_HASH}
DEBUG: |-
Server
WireGuard
networks: {}
Note:
WG_DEVICEmust match the TrueNAS host NIC name. Verify withip link showon the TrueNAS shell.
Step 4: Port Forward on Home Router
Forward UDP port 51821 to TrueNAS (192.168.50.203):
| External Port | Protocol | Internal IP | Internal Port |
|---|---|---|---|
| 51821 | UDP | 192.168.50.203 | 51821 |
Step 5: Add VPS as a Peer
- Open the wg-easy web UI:
http://192.168.50.203:51822 - Log in with your password
- Click + New Client
- Name it
hetzner-vps(or similar) - Click the Download button next to the new peer to get its
.conffile
The downloaded config will look like:
[Interface]
PrivateKey = <generated>
Address = 10.8.0.2/24
DNS = 1.1.1.1
[Peer]
PublicKey = <server public key>
PresharedKey = <generated>
AllowedIPs = 192.168.50.0/24, 10.8.0.0/24
PersistentKeepalive = 120
Endpoint = tnt-mephisto.asuscomm.com:51821
Step 6: Configure WireGuard on the VPS
Copy the downloaded config to the VPS:
scp hetzner-vps.conf user@your-vps:/etc/wireguard/wg-homelab.conf
Or paste it directly on the VPS:
sudo nano /etc/wireguard/wg-homelab.conf
Enable and start the tunnel:
sudo systemctl enable --now wg-quick@wg-homelab
Step 7: Verify Connectivity
From the VPS, ping the TrueNAS server through the tunnel:
ping 10.8.0.1 # wg-easy server VPN IP
ping 192.168.50.203 # TrueNAS LAN IP (routed via WG_ALLOWED_IPS)
Check tunnel status on the VPS:
sudo wg show
You should see the peer with a recent latest handshake timestamp and traffic counters incrementing.
Troubleshooting
No handshake / tunnel not establishing:
- Verify port forward is active:
nc -u tnt-mephisto.asuscomm.com 51821(should not time out immediately) - Check DDNS resolves correctly:
dig tnt-mephisto.asuscomm.com - Confirm
WG_DEVICEmatches the actual NIC:ip link showon TrueNAS shell
Peer connects but homelab LAN unreachable:
- Confirm
WG_ALLOWED_IPSincludes192.168.50.0/24in the wg-easy config - Verify IP forwarding is enabled on TrueNAS:
sysctl net.ipv4.ip_forward(should be1)
wg-easy web UI unreachable:
- Check the container is running:
docker ps | grep wg-easy - Confirm
network_mode: hostis set (required for kernel WireGuard interface access)
Password hash not accepted:
- Ensure all
$characters inPASSWORD_HASHare doubled ($$) in the.envfile
Self-Hosted Netbird VPN Setup Guide
A complete guide to setting up a self-hosted Netbird mesh VPN with Authentik SSO integration.
Table of Contents
- Prerequisites
- Server Setup (VPS)
- Authentik SSO Integration
- Homelab Gateway Setup
- Client Setup
- Troubleshooting
Prerequisites
Required Infrastructure
- VPS/Server with public IP address running Linux
- Domain name with DNS configured
- Traefik reverse proxy with Let’s Encrypt SSL
- Authentik SSO server (optional but recommended)
- Docker and Docker Compose installed
Network Requirements
- Ports required on VPS:
80/tcp- HTTP (Traefik)443/tcp- HTTPS (Traefik)3478/udp- STUN- Optional:
51820/udp- WireGuard (if using relay)
Server Setup (VPS)
Step 1: Run Netbird Installation Script
SSH into your VPS and run the official installation script:
curl -fsSL https://github.com/netbirdio/netbird/releases/latest/download/getting-started.sh | bash
Step 2: Configuration Choices
During installation, you’ll be prompted for:
- Domain: Enter your Netbird domain (e.g.,
netbird.example.com) - Reverse proxy: Select
[1] Existing Traefik - External network: Enter your Traefik network name (e.g.,
web) - HTTPS entrypoint: Enter your Traefik HTTPS entrypoint (usually
websecure) - Certificate resolver: Enter your Let’s Encrypt resolver name (e.g.,
letsencrypt)
The script will generate:
/root/docker-compose.yml- Netbird server containers/root/config.yaml- Netbird configuration/root/dashboard.env- Dashboard environment variables
Step 3: Start Netbird Containers
cd /root
docker compose up -d
Verify containers are running:
docker ps | grep netbird
You should see:
netbird-server- Management + Signal + Relaynetbird-dashboard- Web UI
Step 4: Configure Traefik Routes
If using the Traefik Docker provider, the installation script created labels automatically. However, if using an HTTP provider or file-based configuration, you’ll need to add routes manually.
For Traefik file provider, edit your dynamic_config.yml:
http:
routers:
# gRPC routes (highest priority)
netbird-grpc:
rule: "Host(`netbird.example.com`) && (PathPrefix(`/signalexchange.SignalExchange/`) || PathPrefix(`/management.ManagementService/`))"
service: netbird-server-h2c
entryPoints:
- websecure
priority: 20
tls:
certResolver: letsencrypt
# Backend API routes (medium priority)
netbird-backend:
rule: "Host(`netbird.example.com`) && (PathPrefix(`/api`) || PathPrefix(`/oauth2`) || PathPrefix(`/relay`) || PathPrefix(`/ws-proxy/`))"
service: netbird-server
entryPoints:
- websecure
priority: 10
tls:
certResolver: letsencrypt
# Dashboard (lowest priority - catch-all)
netbird-dashboard:
rule: "Host(`netbird.example.com`)"
service: netbird-dashboard
entryPoints:
- websecure
priority: 1
tls:
certResolver: letsencrypt
services:
netbird-dashboard:
loadBalancer:
servers:
- url: "http://netbird-dashboard:80"
netbird-server:
loadBalancer:
servers:
- url: "http://netbird-server:80"
netbird-server-h2c:
loadBalancer:
servers:
- url: "h2c://netbird-server:80"
Restart Traefik:
docker restart traefik
Step 5: Initial Setup
- Navigate to
https://netbird.example.com - You should see the Netbird setup page
- Create the first admin account:
- Name: Your name
- Email: Your email
- Password: Strong password (temporary - we’ll switch to SSO)
- Complete the setup
Important: This creates a local account with embedded Dex authentication. We’ll replace this with Authentik SSO next.
Authentik SSO Integration
Step 1: Create OAuth2 Provider in Authentik
- Log into Authentik (
https://auth.example.com) - Navigate to Applications → Providers → Create
- Select OAuth2/OpenID Provider
- Configure:
- Name:
Netbird - Authentication flow:
default-authentication-flow - Authorization flow:
default-provider-authorization-explicit-consent - Client type:
Confidential - Redirect URIs/Origins (RegEx):
https://netbird.example.com/oauth2/callback https://netbird.example.com/auth https://netbird.example.com/silent-auth - Signing Key:
authentik Self-signed Certificate
- Name:
- Click Create
- Copy the Client ID and Client Secret - you’ll need these
Step 2: Create Application in Authentik
- Navigate to Applications → Applications → Create
- Configure:
- Name:
Netbird - Slug:
netbird - Provider: Select the Netbird provider you just created
- Name:
- Click Create
Step 3: Add Authentik as Identity Provider in Netbird
- Log into Netbird dashboard (
https://netbird.example.com) - Navigate to Settings → Identity Providers
- Click Add Identity Provider
- Select Authentik
- Configure:
- Name:
Authentik - Issuer URL:
https://auth.example.com/application/o/netbird/ - Client ID: Paste from Authentik
- Client Secret: Paste from Authentik
- Redirect/Callback URL: Copy this URL - you may need to add it to Authentik
- Name:
- Click Add Provider
Step 4: Switch to Authentik Authentication
- Log out of Netbird
- Log back in using Authentik
- After successful login, navigate to Settings → Users
- Transfer ownership from the local admin account to your Authentik account
- Delete the local admin account
Netbird now uses Authentik SSO exclusively!
Homelab Gateway Setup
The gateway peer routes traffic from your VPN clients to your homelab network.
Step 1: Generate Setup Key
- In Netbird dashboard, navigate to Setup Keys
- Click Add Setup Key
- Configure:
- Name:
Homelab Gateway - Reusable: Enable (so you can use it multiple times)
- Expires in: 7 days (or longer)
- Ephemeral Peers: Disable (you want permanent registration)
- Name:
- Click Create
- Copy the setup key
Step 2: Install Netbird Client on Homelab Server
For Docker/TrueNAS (recommended):
Create a new stack in Dockge or docker-compose:
services:
netbird-client:
image: netbirdio/netbird:latest
container_name: netbird-client
hostname: homelab-gateway
restart: unless-stopped
network_mode: host
cap_add:
- NET_ADMIN
- SYS_ADMIN
- SYS_RESOURCE
devices:
- /dev/net/tun
environment:
- NB_SETUP_KEY=YOUR_SETUP_KEY_HERE
- NB_MANAGEMENT_URL=https://netbird.example.com
volumes:
- netbird-client:/var/lib/netbird
volumes:
netbird-client:
Replace YOUR_SETUP_KEY_HERE with your actual setup key.
Deploy:
docker compose up -d
For native Linux installation:
curl -fsSL https://pkgs.netbird.io/install.sh | sh
netbird up --setup-key YOUR_SETUP_KEY_HERE --management-url https://netbird.example.com
Step 3: Configure Network Route
- In Netbird dashboard, navigate to Network Routes
- Click Add Route
- Configure:
- Network: Your homelab subnet (e.g.,
192.168.50.0/24) - Description:
Homelab Network - Routing Peer: Select
homelab-gateway - Metric:
9999(low priority) - Distribution Groups: Leave empty (all peers can use it)
- Network: Your homelab subnet (e.g.,
- Click Create
Now any Netbird client can access your entire homelab network through this gateway!
Client Setup
Mobile (Android/iOS)
- Install Netbird from:
- Android: Google Play Store or F-Droid
- iOS: App Store
- Open the app
- Configure:
- Management URL:
https://netbird.example.com
- Management URL:
- Click Login
- Authenticate via Authentik SSO
- Grant VPN permissions when prompted
The app will connect automatically and you can now access your homelab from your phone!
Laptop (Linux)
Installation
Arch Linux:
yay -S netbird
# or
paru -S netbird
Debian/Ubuntu:
curl -fsSL https://pkgs.netbird.io/install.sh | sh
Any Linux:
curl -fsSL https://pkgs.netbird.io/install.sh | sh
Connect with DNS Disabled
To avoid DNS conflicts with your local/work network:
sudo netbird up --disable-dns
This connects to Netbird while preserving your local DNS settings.
Make DNS Disable Permanent
To automatically connect on boot without DNS override:
sudo systemctl edit netbird
Add:
[Service]
ExecStart=
ExecStart=/usr/bin/netbird service run --disable-dns
Save and exit, then:
sudo systemctl daemon-reload
sudo systemctl enable netbird
sudo systemctl start netbird
Verify Connection
netbird status
You should see:
- Management: Connected
- Signal: Connected
- Peers: Shows your gateway and other clients
- Networks:
192.168.50.0/24(or your homelab subnet)
Test homelab access:
ping 192.168.50.1 # Replace with your homelab IP
Laptop (macOS)
- Download Netbird from netbird.io
- Install the
.dmgpackage - Open Netbird from Applications
- Configure:
- Management URL:
https://netbird.example.com
- Management URL:
- Click Login
- Authenticate via Authentik
- Grant VPN permissions
Laptop (Windows)
- Download Netbird from netbird.io
- Run the installer
- Open Netbird from Start Menu
- Configure:
- Management URL:
https://netbird.example.com
- Management URL:
- Click Login
- Authenticate via Authentik
- Grant VPN permissions
Troubleshooting
Server Issues
Dashboard not accessible:
- Check containers are running:
docker ps | grep netbird - Check Traefik routes are configured correctly
- Verify DNS points to your VPS IP
- Check Traefik logs:
docker logs traefik
502 Bad Gateway:
- Ensure
netbird-serverandnetbird-dashboardcontainers are running - Verify they’re on the correct Docker network
- Check Traefik can reach the containers
OAuth errors during Authentik login:
- Verify redirect URIs match exactly in Authentik provider
- Check Client ID and Secret are correct
- Ensure Issuer URL ends with
/(e.g.,https://auth.example.com/application/o/netbird/)
Client Issues
Can’t connect to management server:
- Verify management URL:
https://netbird.example.com - Check firewall allows outbound HTTPS
- Ensure SSL certificate is valid
DNS broken after connecting (Linux):
- Disconnect:
sudo netbird down - Reconnect with DNS disabled:
sudo netbird up --disable-dns - Make it permanent using systemd override (see Linux client setup)
Can’t reach homelab:
- Verify network route exists in Netbird dashboard
- Check gateway peer is connected:
netbird status - Ping gateway peer:
ping 100.86.236.236(use actual gateway Netbird IP) - Verify homelab subnet is correct (e.g.,
192.168.50.0/24)
Connection shows as “Relayed” instead of “P2P”:
- This is normal for peers behind symmetric NAT
- Traffic goes through your VPS relay server
- Still secure and functional, just slightly higher latency
Network Route Not Working
- Check route is enabled in Netbird dashboard
- Verify gateway peer is online
- Ensure gateway peer accepted the network route
- Check routing peer has proper network access
- Verify firewall on gateway allows traffic forwarding
Security Best Practices
- Use Authentik SSO: Centralized authentication with MFA support
- Enable MFA: Configure YubiKey or TOTP in Authentik
- Limit network routes: Only expose necessary subnets
- Use access control: Configure peer groups and policies in Netbird
- Monitor logs: Regularly check Netbird and Authentik logs
- Rotate setup keys: Set expiration dates on setup keys
- Review peer list: Regularly audit connected peers
Advanced Configuration
Multiple Network Routes
You can expose multiple networks:
- Create separate network routes for each subnet
- Assign different routing peers if needed
- Use distribution groups to control which clients can access which networks
Peer Groups and Policies
Control which peers can communicate:
- Create peer groups in Netbird dashboard
- Define access control policies
- Restrict peer-to-peer connections as needed
Custom DNS
Configure Netbird’s DNS for internal name resolution:
- Navigate to DNS in Netbird dashboard
- Add DNS nameservers
- Configure custom domains
High Availability
For production deployments:
- Run multiple Netbird server instances
- Use external PostgreSQL database
- Configure load balancer for management API
- Deploy multiple relay servers
Appendix: Architecture Overview
Network Topology
┌─────────────────┐
│ VPS Server │
│ ┌───────────┐ │
│ │ Netbird │ │
│ │ Server │ │
│ └─────┬─────┘ │
│ │ │
└────────┼────────┘
│
┌────┴────┐
│ │
┌───▼──┐ ┌──▼───┐
│Client│ │Client│
│Peer 1│ │Peer 2│
└──────┘ └──┬───┘
│
┌────▼─────┐
│ Homelab │
│ Gateway │
└────┬─────┘
│
┌──────▼──────┐
│ Homelab │
│ Network │
│192.168.50.x │
└─────────────┘
Component Responsibilities
Netbird Server (VPS):
- Management API - peer registration and configuration
- Signal server - WebRTC signaling for P2P connections
- Relay server - fallback relay for NAT traversal
- STUN server - NAT type detection
Netbird Dashboard:
- Web UI for administration
- User and peer management
- Network route configuration
- Access control policies
Gateway Peer:
- Routes traffic to homelab network
- Runs as Docker container or native service
- Requires network_mode: host for routing
Client Peers:
- Connect to Netbird management server
- Establish P2P or relayed connections
- Route homelab traffic through gateway
- Optional: –disable-dns to preserve local DNS
Pi-hole Setup Tutorial
A step-by-step guide to setting up Pi-hole as a network-wide ad blocker and DNS server using Docker and Dockge, based on a real homelab deployment.
Prerequisites
- Docker and Dockge installed on your server
- A static IP for your server (e.g.
192.168.50.203) - Basic familiarity with Docker Compose
1. Create the Compose Stack
In Dockge, create a new stack called pihole with the following compose.yaml:
# /opt/docker/stacks/pihole/compose.yaml
services:
pihole:
image: pihole/pihole:latest
container_name: pihole
restart: unless-stopped
labels:
- homepage.group=Homelab
- homepage.name=Pi-hole
- homepage.icon=pi-hole.png
- homepage.href=http://192.168.50.203:20720/admin/
- homepage.description=Network ad blocker & DNS
ports:
- 53:53/tcp
- 53:53/udp
- 20720:20720/tcp
volumes:
- /path/to/configs/pihole/config:/etc/pihole
- /path/to/configs/pihole/dns:/etc/dnsmasq.d
environment:
TZ: Europe/Brussels
FTLCONF_dns_listeningMode: all
FTLCONF_webserver_api_password: ${WEBPASSWORD}
FTLCONF_webserver_port: "20720"
DNSMASQ_USER: pihole
FTL_CMD: no-daemon
networks:
- dns
networks:
dns:
name: dns
Note: Replace
/path/to/configswith your actual config path. Thednsnetwork is shared with Unbound — create it first if Unbound is already running, or create it manually withdocker network create dns.
Create a .env file in the same directory with your admin password:
WEBPASSWORD=your_secure_password_here
2. Configure Your Router
Pi-hole needs to be the DNS server for all devices on your network. There are two places to configure this on most routers:
- LAN / DHCP DNS Server 1:
192.168.50.203(your server’s IP) - LAN / DHCP DNS Server 2: leave empty, or set to your secondary Pi-hole when ready
Remove any fallback DNS like 8.8.8.8 — this would allow devices to bypass Pi-hole.
3. Configure Upstream DNS
In the Pi-hole web UI, go to Settings > DNS.
- Uncheck all preset upstream DNS providers
- Under Custom DNS servers, enter:
unbound#5335 - Click Save
This assumes Unbound is running as a container on the same Docker network. If you haven’t set up Unbound yet, temporarily use
1.1.1.1(Cloudflare) as upstream.
4. Set Up Group Management
Pi-hole’s group management lets you apply different blocking rules to different devices.
Create Groups
Go to Groups and create:
Default— full blocking (already exists)Unblocked— for family members who don’t want blockingIoT-blocked— for smart devices you want to block from the internet
Assign Adlists to Groups
Go to Adlists and for each blocklist, uncheck the Unblocked group. This means devices in that group have no blocklists applied.
Block IoT Devices Completely
Go to Domains > RegEx filter tab and add:
- Regex:
.* - Type: Deny
- Group:
IoT-blocked
This blocks all DNS resolution for devices in that group.
Assign Clients to Groups
Go to Clients, add each device by IP address, and assign it to the appropriate group.
Important: Make sure IoT devices have static IPs (either set on the device or via DHCP reservation in your router) so group assignments don’t break when IPs change.
5. Verify Everything Works
Check that DNS queries are flowing through Pi-hole by visiting a few websites and checking the Query Log in the Pi-hole web UI.
You should see:
- Your devices’ IPs appearing as clients
- Queries being forwarded to
unbound#5335 - Blocked queries showing in red
Tips
- Use the Query Log to identify false positives — you can whitelist domains with one click
- Start with a conservative blocklist (e.g. Steven Black unified or HaGeZi Multi Normal) to minimize false positives
- The Dashboard shows real-time stats on blocked queries, top domains, and top clients
Unbound Setup Tutorial
A step-by-step guide to setting up Unbound as a local recursive DNS resolver alongside Pi-hole, using Docker and Dockge.
What is Unbound?
Unbound is a validating, recursive, caching DNS resolver. Instead of forwarding your DNS queries to a third-party provider like Google or Cloudflare, it resolves them itself by walking the DNS tree from root servers. This gives you:
- Privacy — no single DNS provider sees all your queries
- DNSSEC validation — cryptographic verification of DNS responses
- Performance — fast cached lookups after the first query
The recommended setup is to run Unbound alongside Pi-hole:
Device → Pi-hole (ad blocking) → Unbound (recursive resolution) → Root servers
Image
This guide uses klutchell/unbound, which supports both amd64 and arm64 (Raspberry Pi). It is a distroless image — no shell or package manager is included, so docker exec with shell commands will not work. Use dig from the host for debugging instead.
The image manages root.key, root.hints, and DNSSEC trust anchor bootstrapping internally. You only need to provide a custom config file with your overrides.
Prerequisites
- Docker and Dockge installed
- Pi-hole already running (see Pi-hole setup guide)
- A shared Docker network called
dns(created during Pi-hole setup) — only needed if Pi-hole uses bridge networking
1. Create the Config Directory
mkdir -p /path/to/configs/unbound
2. Create unbound.conf
Create /path/to/configs/unbound/unbound.conf:
server:
# Network
interface: 0.0.0.0@5335
do-ip4: yes
do-ip6: no
do-udp: yes
do-tcp: yes
do-daemonize: no
# Performance
serve-expired: yes
serve-expired-ttl: 604800
serve-expired-ttl-reset: yes
prefetch: yes
prefetch-key: yes
so-reuseport: yes
cache-min-ttl: 300
cache-max-ttl: 86400
# Security
harden-glue: yes
harden-dnssec-stripped: yes
harden-algo-downgrade: yes
harden-below-nxdomain: yes
harden-large-queries: yes
harden-short-bufsize: yes
hide-identity: yes
hide-version: yes
deny-any: yes
aggressive-nsec: yes
# Privacy
use-caps-for-id: yes
qname-minimisation: yes
# Access control
access-control: 127.0.0.1/32 allow
access-control: 192.168.50.0/24 allow # adjust to your LAN subnet
access-control: 172.16.0.0/12 allow # Docker network range
# Logging (set verbosity to 0 in production)
logfile: ""
verbosity: 0
remote-control:
control-enable: no
# Forward zones for CDN domains with dynamic subdomains
# These are forwarded to fast upstream resolvers instead of recursive resolution
forward-zone:
name: "googlevideo.com"
forward-addr: 8.8.8.8
forward-addr: 8.8.4.4
forward-zone:
name: "googleapis.com"
forward-addr: 8.8.8.8
forward-addr: 8.8.4.4
forward-zone:
name: "gstatic.com"
forward-addr: 8.8.8.8
forward-addr: 8.8.4.4
forward-zone:
name: "microsoft.com"
forward-addr: 1.1.1.1
forward-addr: 1.0.0.1
forward-zone:
name: "office.com"
forward-addr: 1.1.1.1
forward-addr: 1.0.0.1
forward-zone:
name: "office365.com"
forward-addr: 1.1.1.1
forward-addr: 1.0.0.1
forward-zone:
name: "akamaiedge.net"
forward-addr: 1.1.1.1
forward-addr: 1.0.0.1
forward-zone:
name: "akamaized.net"
forward-addr: 1.1.1.1
forward-addr: 1.0.0.1
forward-zone:
name: "cloudfront.net"
forward-addr: 1.1.1.1
forward-addr: 1.0.0.1
forward-zone:
name: "fastly.net"
forward-addr: 1.1.1.1
forward-addr: 1.0.0.1
Note: Do not include
username,directory,auto-trust-anchor-file, orroot-hints— these are managed internally by the image. The172.16.0.0/12access-control entry is required for Pi-hole to reach Unbound over the Docker bridge network.
3. Create the Compose Stack
In Dockge, create a new stack called unbound with the following compose.yaml:
services:
unbound:
image: klutchell/unbound:latest
container_name: unbound
restart: unless-stopped
ports:
- "5335:5335/udp"
- "5335:5335/tcp"
volumes:
- /path/to/configs/unbound:/etc/unbound/custom.conf.d
healthcheck:
disable: true
networks:
- dns
networks:
dns:
external: true
name: dns
Note: The config volume mounts into
/etc/unbound/custom.conf.d, not/opt/unbound/etc/unboundas used by other images.
Important: If Pi-hole uses host networking instead of the
dnsbridge network, remove thenetworkssection from both the service and the bottom of the file, and ensureaccess-control: 172.16.0.0/12 allowis still present for any other Docker-networked clients.
4. Configure Pi-hole Upstream
In Pi-hole’s settings, set the custom upstream DNS to:
- If Pi-hole uses bridge networking (dns network): use the Unbound container’s IP on the dns network, port 5335
- If Pi-hole uses host networking: use
127.0.0.1#5335
In pihole.toml or via the Pi-hole web UI, this looks like:
127.0.0.1#5335
5. Verify Unbound is Working
Test basic resolution:
dig @<unbound-host-ip> -p 5335 google.com
Test caching (second query should be ~0ms):
dig @<unbound-host-ip> -p 5335 google.com
dig @<unbound-host-ip> -p 5335 google.com
Test DNSSEC validation:
# Should return NOERROR with 'ad' flag
dig @<unbound-host-ip> -p 5335 sigok.verteiltesysteme.net
# Should return SERVFAIL (invalid signature rejected)
dig @<unbound-host-ip> -p 5335 sigfail.verteiltesysteme.net
Test full chain through Pi-hole:
dig @<pihole-ip> google.com
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Container exits immediately | Config syntax error | Check docker logs unbound; fix syntax in unbound.conf |
REFUSED responses | IP not in access-control | Add your subnet to access-control in config |
| Slow browsing on first visit | Cold cache recursive lookup | Normal — warms up with use; serve-expired helps |
| Very slow YouTube/streaming | Dynamic CDN subdomains | Add forward zones for googlevideo.com etc. |
| Pi-hole not forwarding to Unbound | Wrong upstream IP or port | Verify upstream is set to correct IP/port in Pi-hole; check access-control includes Docker subnet |
docker exec commands fail | Distroless image — no shell | Use dig from the host instead |
| Container shows unhealthy | Built-in healthcheck uses internal tooling | Add healthcheck: disable: true to compose |
Backups
backups
${template.each(query[[ from p = index.tag “page” where string.find(p.name, editor.getCurrentPage() .. “/”, 1, true) ]], templates.pageItem)}
Hetzner Storage Box Setup Guide
This guide walks you through setting up a Hetzner Storage Box for use with Backrest backup solution.
Prerequisites
- Hetzner Cloud account
- SSH key pair generated on your system
Step 1: Create Storage Box
- Log in to Hetzner Console
- Navigate to Storage Boxes in the sidebar
- Click Create Storage Box
Step 2: Select Configuration
Location
- Choose Helsinki (eu-central)
- Provides geographic separation from Falkenstein VPS
- Good for disaster recovery
Storage Size
- Select BX11 (1TB) - €3.87/month
- Sufficient for configurations and essential data
- Excludes media files
- Can upgrade later without data loss
Step 3: Configure Access
SSH Keys
During creation, add your SSH public keys:
- Add keys from all machines that will access the Storage Box
- Keys must be in OpenSSH format for port 23
Access Protocols
Enable the following:
- ✅ SSH Support (required for SFTP backups)
- ✅ External Reachability (required for connections from outside Hetzner network)
- ❌ SMB Support (optional, not needed for backups)
- ❌ WebDAV Support (optional, not needed for backups)
Step 4: Note Credentials
After creation, save these details:
- Username:
uXXXXXX(where XXXXXX is your box number) - Hostname:
uXXXXXX.your-storagebox.de - SSH Port:
23(not the standard 22!) - Password: Set via Hetzner Console if needed
Step 5: Add SSH Keys to Storage Box
SSH keys must be added to the Storage Box’s authorized_keys file:
# Connect to Storage Box with password
sftp -P 23 uXXXXXX@uXXXXXX.your-storagebox.de
# Create .ssh directory (if it doesn't exist)
mkdir .ssh
chmod 700 .ssh
# Upload your public key
put /path/to/your/id_ed25519.pub .ssh/authorized_keys
# Set correct permissions
chmod 600 .ssh/authorized_keys
# Exit
bye
Or via SSH (if port 23 interactive access works):
ssh uXXXXXX@uXXXXXX.your-storagebox.de -p 23
mkdir -p .ssh
chmod 700 .ssh
echo "your-public-key-content" >> .ssh/authorized_keys
chmod 600 .ssh/authorized_keys
exit
Step 6: Test Connection
Verify SSH key authentication works:
# Test connection (should not ask for password)
ssh -p 23 uXXXXXX@uXXXXXX.your-storagebox.de ls
# Or via SFTP
sftp -P 23 uXXXXXX@uXXXXXX.your-storagebox.de
If successful, you should connect without entering a password.
Step 7: Verify Settings
In Hetzner Console, confirm:
- ✅ SSH Support enabled
- ✅ External Reachability enabled
- ✅ Your SSH keys are listed
Storage Box URI Format
For Backrest repository configuration, use:
sftp://uXXXXXX@uXXXXXX.your-storagebox.de:23/backup-path
Important:
- Use
sftp://with double slashes - Include port
:23in the URI - The path
/backup-pathwill be created automatically by Restic
Security Notes
- All data is encrypted in transit (SSH/SFTP)
- Storage Box provides RAID redundancy
- Geographic separation from your servers
- SSH key authentication is more secure than passwords
- Keep your SSH private keys secure
- Store your Restic encryption password separately and securely
Troubleshooting
“Permission denied” errors
- Verify SSH key is in
authorized_keyson Storage Box - Check key file permissions (600 for private key)
- Ensure you’re using the correct username
“Connection refused” or “Connection reset”
- Verify External Reachability is enabled
- Check SSH port is 23, not 22
- Verify hostname is correct
“Host key verification failed”
- Add Storage Box to known_hosts:
ssh-keyscan -p 23 uXXXXXX.your-storagebox.de >> ~/.ssh/known_hosts
Next Steps
After setting up the Storage Box, proceed to the Backrest setup guide to configure automated backups.
Cost
- BX11: €3.87/month (1TB storage)
- Unlimited traffic included
- No additional costs for snapshots (up to 10 for BX11)
Additional Resources
Backrest Backup Service Setup Guide
This guide walks you through setting up Backrest on TrueNAS Scale to back up both local homelab and remote VPS data to Hetzner Storage Box.
Architecture Overview
- Backrest runs on: TrueNAS homelab
- Backs up: Local homelab data + remote VPS data (via SSHFS)
- Stores backups in: Hetzner Storage Box (via SFTP)
- Web UI: Accessible at
http://homelab-ip:12001
Prerequisites
- TrueNAS Scale system (homelab)
- Remote VPS with WireGuard connection to homelab
- Hetzner Storage Box configured (see Storage Box setup guide)
- Docker and Dockge installed on TrueNAS
- FUSE kernel module available (
/dev/fuse) - SSH key pair for authentication
Part 1: Prepare SSH Keys
Step 1: Generate SSH Key on Homelab
# Create SSH directory for Backrest
mkdir -p ${CONFIG_PATH}/backrest/ssh
chmod 700 ${CONFIG_PATH}/backrest/ssh
# Generate ED25519 key (no passphrase for automation)
ssh-keygen -t ed25519 -f ${CONFIG_PATH}/backrest/ssh/id_ed25519 -N "" -C "backrest-homelab"
# Set correct permissions
chmod 600 ${CONFIG_PATH}/backrest/ssh/id_ed25519
chmod 644 ${CONFIG_PATH}/backrest/ssh/id_ed25519.pub
Step 2: Authorize Key on Storage Box
# Get your public key
cat ${CONFIG_PATH}/backrest/ssh/id_ed25519.pub
# Connect to Storage Box
sftp -P 23 uXXXXXX@uXXXXXX.your-storagebox.de
# In SFTP session:
mkdir .ssh
chmod 700 .ssh
put ${CONFIG_PATH}/backrest/ssh/id_ed25519.pub .ssh/authorized_keys
chmod 600 .ssh/authorized_keys
bye
Step 3: Authorize Key on VPS
# Copy public key to VPS
ssh-copy-id -i ${CONFIG_PATH}/backrest/ssh/id_ed25519.pub root@VPS_IP
# Or manually:
cat ${CONFIG_PATH}/backrest/ssh/id_ed25519.pub | ssh root@VPS_IP "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"
Step 4: Add Known Hosts
# Add Storage Box to known_hosts
ssh-keyscan -p 23 uXXXXXX.your-storagebox.de | tee ${CONFIG_PATH}/backrest/ssh/known_hosts
# Add VPS to known_hosts
ssh-keyscan VPS_IP >> ${CONFIG_PATH}/backrest/ssh/known_hosts
# Set permissions
chmod 644 ${CONFIG_PATH}/backrest/ssh/known_hosts
Step 5: Set Correct Ownership
# Backrest container runs as root, so files should be owned by root
sudo chown -R root:root ${CONFIG_PATH}/backrest/ssh
Part 2: Enable FUSE Support
SSHFS requires the FUSE kernel module.
Step 1: Load FUSE Module
# Load FUSE module
sudo modprobe fuse
# Verify it loaded
lsmod | grep fuse
# Verify device exists
ls -l /dev/fuse
Should show: crw-rw-rw- 1 root root 10, 229 ...
Step 2: Make FUSE Persistent (Optional)
To ensure FUSE loads on boot:
# Add to modules
echo "fuse" | sudo tee -a /etc/modules
# Or create systemd service
sudo tee /etc/systemd/system/load-fuse.service << 'EOF'
[Unit]
Description=Load FUSE kernel module
Before=docker.service
[Service]
Type=oneshot
ExecStart=/sbin/modprobe fuse
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl enable load-fuse.service
Part 3: Deploy Backrest with Dockge
Step 1: Create Backrest Stack in Dockge
Create a new stack named backrest with the following compose file:
services:
backrest:
image: garethgeorge/backrest:latest
restart: unless-stopped
privileged: true
cap_add:
- SYS_ADMIN
devices:
- /dev/fuse
environment:
- BACKREST_PORT=0.0.0.0:9898
- BACKREST_DATA=/data
- BACKREST_CONFIG=/config/config.json
- XDG_CACHE_HOME=/cache
- TZ=Europe/Brussels
volumes:
- ${CONFIG_PATH}/backrest/data:/data
- ${CONFIG_PATH}/backrest/config:/config
- ${CONFIG_PATH}/backrest/cache:/cache
- ${CONFIG_PATH}/backrest/ssh:/root/.ssh:ro
- /mnt/pool:/mnt/pool:ro
command: >
sh -c "
apk add --no-cache sshfs openssh-client &&
mkdir -p /mnt/vps &&
sshfs -o allow_other,StrictHostKeyChecking=no,IdentityFile=/root/.ssh/id_ed25519 root@VPS_IP:/ /mnt/vps &
sleep 2 &&
exec /backrest
"
ports:
- 12001:9898
networks: {}
Important replacements:
- Replace
VPS_IPwith your VPS IP address (preferably WireGuard IP like10.8.0.1) - Replace
/mnt/poolwith your actual TrueNAS pool mount point - Adjust
${CONFIG_PATH}if needed (or use absolute paths) - Adjust
TZto your timezone
Step 2: Deploy the Stack
- In Dockge, click Deploy
- Wait for the container to start
- Check logs for any errors
Step 3: Verify SSHFS Mount
# Check if VPS is mounted
sudo docker exec backrest-backrest-1 ls /mnt/vps
# Should show VPS directory structure: bin, etc, opt, var, etc.
Step 4: Verify Backrest is Running
# Check logs
sudo docker logs backrest-backrest-1
# Should see: "starting web server" and "addr": "0.0.0.0:9898"
Part 4: Configure Backrest Repository
Step 1: Access Backrest Web UI
Open your browser and navigate to:
http://homelab-ip:12001
Step 2: Initial Settings
On first launch, configure:
- Instance ID:
homelab-backrest(or any unique name) - Authentication: Create a username and password
Click Save.
Step 3: Add Storage Box Repository
-
Navigate to Repositories → + Add Repo
-
Fill in the details:
Repo Details:
- Repo Name:
tnt-backup(or your preferred name) - Repository URI:
sftp://uXXXXXX@uXXXXXX.your-storagebox.de:23/tnt-backup- Replace
uXXXXXXwith your Storage Box username - The
/tnt-backuppath will be created automatically
- Replace
Password:
- Click [Generate] to create a strong encryption password
- CRITICAL: Copy this password and store it securely!
- Without this password, your backups are permanently unrecoverable
Auto Unlock:
- ✅ Check this box (for single-user setup)
- Repo Name:
-
Click Test Configuration
- Should show success
-
Click Submit
- Repository will be initialized
Part 5: Create Backup Plans
Homelab Backup Plan
-
Navigate to Plans → + Add Plan
-
Configure:
Plan Details:
- Plan Name:
homelab-backup - Repository:
tnt-backup
Backup Scope:
-
Paths: Add the following:
/mnt/pool/docker/mnt/pool/appdata- (Add other important directories)
-
Excludes: Add the following:
/mnt/pool/media(if media is large and not critical)*.log*.tmpcache/.cache/
Backup Schedule:
- Schedule Type: Cron
- Cron Expression:
0 3 * * 0(Sunday at 3 AM) - Reference Clock: Local
Retention Policy:
- By Time Period (selected)
- Hourly: 0
- Daily: 7
- Weekly: 4
- Monthly: 6
- Yearly: 0
- Plan Name:
-
Click Submit
VPS Backup Plan
-
Navigate to Plans → + Add Plan
-
Configure:
Plan Details:
- Plan Name:
vps-backup - Repository:
tnt-backup
Backup Scope:
-
Paths: Add the following:
/mnt/vps/opt/docker/mnt/vps/etc
-
Excludes: Add the following:
*.log*.tmpcache/.cache/
Backup Schedule:
- Schedule Type: Cron
- Cron Expression:
0 2 * * 0(Sunday at 2 AM - 1 hour before homelab) - Reference Clock: Local
Retention Policy:
- By Time Period (selected)
- Hourly: 0
- Daily: 7
- Weekly: 4
- Monthly: 6
- Yearly: 0
- Plan Name:
-
Click Submit
Part 6: Set Up Notifications (Recommended)
Using Healthchecks.io
-
Create account at Healthchecks.io
-
Create a check for each backup plan:
- Name:
Homelab Backup - Schedule: Weekly
- Grace Time: 2 hours
- Name:
-
In Backrest, edit each backup plan:
- Go to Advanced → Hooks
- Click + Add Hook
- On Success: HTTP GET to healthcheck URL
- On Error: HTTP GET to healthcheck URL/fail
-
Save the plan
This will alert you if:
- Backups fail to run
- Backups complete with errors
- Backups don’t run on schedule
Part 7: Testing
Test Manual Backup
- In Backrest UI, navigate to Plans
- Click on
homelab-backup - Click Backup Now
- Monitor the progress in the UI
- Verify backup completes successfully
Repeat for vps-backup.
Test Restore
- Navigate to Plans → Select a plan
- Click on a completed backup snapshot
- Browse files in the backup
- Select a file and click Restore
- Verify file is restored
Maintenance
Regular Checks
- Weekly: Check backup logs for successful completion
- Monthly: Run
restic checkvia Backrest to verify repository integrity - Quarterly: Test a full restore operation
Monitoring Storage Usage
- Check Storage Box usage in Hetzner Console
- Monitor disk graphs in Backrest dashboard
- Adjust retention policy if space becomes constrained
Updating Backrest
When new versions are released:
- In Dockge, update image tag or pull latest
- Redeploy the stack
- Verify backups continue to run
Troubleshooting
Container Won’t Start
# Check logs
sudo docker logs backrest-backrest-1
# Common issues:
# - FUSE module not loaded: sudo modprobe fuse
# - SSH key permissions wrong: sudo chown -R root:root ${CONFIG_PATH}/backrest/ssh
# - VPS unreachable: Check WireGuard connection
SSHFS Mount Failed
# Test SSH connection to VPS
ssh -i ${CONFIG_PATH}/backrest/ssh/id_ed25519 root@VPS_IP
# If fails, check:
# - SSH key authorized on VPS
# - VPS firewall allows SSH
# - WireGuard tunnel is up
Backups Failing
# Check Backrest logs
sudo docker logs backrest-backrest-1
# Test Storage Box connection
ssh -p 23 -i ${CONFIG_PATH}/backrest/ssh/id_ed25519 uXXXXXX@uXXXXXX.your-storagebox.de ls
# Verify External Reachability is enabled in Hetzner Console
Web UI Not Accessible
# Verify BACKREST_PORT is set to 0.0.0.0:9898
sudo docker exec backrest-backrest-1 env | grep BACKREST_PORT
# Check if port is listening
sudo docker exec backrest-backrest-1 netstat -tlnp | grep 9898
Security Considerations
- SSH Keys: Keep private keys secure, use different keys per machine
- Encryption Password: Store Restic password in password manager
- Network: Backrest UI has authentication enabled
- Updates: Keep Backrest and base images updated
- Access: Limit who can access Backrest web UI
Performance Tips
- Exclude Large Files: Don’t back up media files unless necessary
- Adjust Schedules: Space out backup times to reduce load
- Retention: Balance between backup history and storage costs
- Network: Use WireGuard IP for VPS connection (faster than public internet)
Backup Strategy Summary
What’s Backed Up:
- ✅ Homelab Docker configurations and data
- ✅ Homelab application data
- ✅ VPS Docker configurations
- ✅ VPS system configurations (
/etc) - ❌ Media files (too large, can be re-downloaded)
Backup Frequency:
- Weekly automatic backups (Sunday nights)
Retention:
- 7 daily backups (~1 week of daily history)
- 4 weekly backups (~1 month of weekly history)
- 6 monthly backups (~6 months of long-term history)
Recovery Time:
- Configs/small files: Minutes
- Large datasets: Hours (depends on internet speed)
Cost Breakdown
- Hetzner Storage Box BX11: €3.87/month (1TB)
- Total monthly cost: €3.87
For automated, encrypted, off-site backups of critical infrastructure.
Next Steps
After setup is complete:
- ✅ Test backups run successfully
- ✅ Verify notifications work
- ✅ Document your Restic encryption password securely
- ✅ Test a restore operation
- ✅ Set calendar reminders for quarterly restore tests