Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

  1. Download the base eXoDOS collection and the separate eXoDOS Linux Patch.zip from retro-exo.com.
  2. Extract the Linux Patch zip into the same root folder as your eXoDOS download (where Setup eXoDOS.bat and eXoDOS Catalog.pdf live). Files should land directly in that root, not a subfolder.
  3. Install flatpak first if you don’t have it: sudo pacman -S flatpak (log out and back in afterward).
  4. 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.
  5. Run Setup eXoDOS.command (not the .bat file). Follow the prompts for fullscreen/windowed, aspect ratio, and adult content filtering.
  6. Launch exogui (either from the desktop shortcut it creates, or exogui.command directly) 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

  1. Launch the tool: ./ExoDOSConverter.sh
  2. Point collectionDir at your installed eXoDOS folder.
  3. Set conversionType to Batocera or RetroPie.
  4. Select your curated games from the list (or load/save a .sel selection file for reuse).
  5. Run the conversion.

For each game, expect:

  • A .zip file (the game itself, packaged for DOSBox-Pure’s zip-loading support)
  • A .keys file (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 .keys file 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.xml or 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: ” to confirm whether auto-detection kicked in.

Known incompatibilities

  • Games whose original DOSBox config relies on the mount command 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:

  1. Input module firmware (keyboard, numpad, macropad) — must be done first
  2. 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:

  1. Input module firmware flashes immediately and inline — no reboot required for this step.
  2. 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 / FeatureDetails
CPU power table correctedCPU was incorrectly capped at 35W under load
False “Fan Not Detected” error fixedSpurious fan warning on boot eliminated
Lid-close wake fixedKeyboard/input modules no longer wake the system when lid is closed
Dual-port adapter stability100W adapter no longer disconnects when switching ports
POST RAM test fixedNow correctly validates more than 64GB of memory
PROCHOT throttling fixedCPU no longer throttles unexpectedly under heavy load
NVIDIA RTX 5070 support addedNot relevant unless you have the GPU expansion module

Troubleshooting

Update doesn’t show up in get-updates

  • Run fwupdmgr refresh --force again
  • Make sure you’re on AC power
  • Check that the LVFS stable remote is enabled: fwupdmgr get-remotes
  • If the lvfs remote shows enabled: 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

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.pub or ~/.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-vps
  • nextcloud-server
  • belgium-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

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 Y to confirm
  • Press Enter to 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 attempts
  • findtime = 600: Within 10 minutes
  • bantime = 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

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

  1. Create admin account
    • Email + password
    • Enable 2FA (TOTP)
    • Optional: Add YubiKey
  2. Create organization
    • Name: homelab (or your choice)
    • Subnet: Use Gerbil’s subnet from Step 2 (e.g., 100.89.128.0/24)

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:

  1. Sites → Add Site
  2. Fill in:
    • Name: tnt-truenas
    • Connection Type: Newt tunnel
    • Address: 100.89.128.2 (next IP after .1)
  3. Create Site
  4. 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:

  1. Sites → Click tnt-truenas
  2. General tab
  3. Remote Subnets: Add 192.168.0.0/24 (your homelab network)
  4. Save

Verify site shows “Online” (green)

Step 9: Expose Homelab Services

For each service (Jellyfin, Romm, etc.):

  1. Resources → Add Resource
  2. Fill in:
    • Name: Jellyfin
    • Subdomain: jellyfin
    • Base Domain: yourdomain.com
  3. Add Target:
    • Site: tnt-truenas
    • Protocol: http
    • IP: 192.168.50.203 (TrueNAS IP)
    • Port: 8096 (service port)
  4. Authentication Tab:
    • Use Platform SSO: ON (for 2FA protection)
  5. 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 568 and UID 1001 to match your TrueNAS dataset ownership. Check with ls -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

  1. Install the Remote - SSH extension in VS Code
  2. Open the Command Palette (Cmd+Shift+P / Ctrl+Shift+P)
  3. Run Remote-SSH: Connect to Host
  4. Select truenas-data
  5. VS Code will install its server inside the container and connect
  6. 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 container
  • Host(\vps-test.mydomain.com`)`: Match this domain
  • websecure: Use HTTPS (port 443)
  • letsencrypt: Get SSL certificate automatically
  • port=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

  1. Open browser: https://vps-test.mydomain.com
  2. 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 domain
  • www.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

  1. Open browser: https://www.yourdomain.com
  2. Should see:
    • ✅ Valid SSL certificate (green padlock)
    • ✅ Your website loading
    • ✅ No certificate warnings
  3. Test redirect (if configured): https://yourdomain.com → https://www.yourdomain.com
  4. 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_NAME to 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

  1. Go to your GitHub repository
  2. Click Actions tab (top menu)
  3. You should see a workflow running: “Build and Deploy to VPS”
  4. Click on it to see live progress

Workflow steps:

  1. ✅ Checkout code (~5 seconds)
  2. ✅ Setup Node.js (~10 seconds)
  3. ✅ Install dependencies (~30-60 seconds)
  4. ✅ Build (~10-30 seconds)
  5. ✅ Setup SSH (~2 seconds)
  6. ✅ Deploy to VPS (~5-15 seconds depending on file size)
  7. ✅ Restart container (~2 seconds)
  8. ✅ Cleanup (~1 second)

Total time: ~1-2 minutes

Step 3.6: Verify Auto-Deployment

Once the workflow completes successfully:

  1. Check your website: https://www.yourdomain.com
  2. 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:

  1. Push triggers GitHub Actions
  2. GitHub builds your site
  3. Deploys to VPS automatically
  4. Site updates in ~1-2 minutes
  5. 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
  • dig shows 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:

  1. Go to GitHub: Settings → Secrets and variables → Actions
  2. Check if VPS_HOST exists
  3. If missing: New repository secret
    • Name: VPS_HOST
    • Value: Your VPS IP (e.g., 123.45.67.89)
  4. 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) or Cmd+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:

  1. Verify SSH key in VPS authorized_keys:
# On VPS
cat ~/.ssh/authorized_keys | grep github-actions
# Should see your key
  1. 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
  1. 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

  1. Log into GitHub
  2. Click your profile picture (top right)
  3. Go to Settings
  4. Scroll down in left sidebar
  5. Click Developer settings (near bottom)
  6. Click OAuth Apps
  7. 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:// (not http://)
  • /callback path 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

  1. Click Register application
  2. 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:

  1. Click Generate a new client secret button
  2. IMPORTANT: Copy the secret immediately!
  3. It’s only shown once - you can’t see it again
  4. 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:

  1. Login → Domain Management
  2. Click your domain → DNS Records
  3. Add Advanced DNS Record
  4. Type: A
  5. Refers to: Select “Other Host” → type oauth
  6. Points to: Your VPS IP (e.g., 123.45.67.89)
  7. TTL: 3600
  8. 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:

  1. OAUTH_CLIENT_ID: Your GitHub Client ID from Part 1
  2. OAUTH_CLIENT_SECRET: Your GitHub Client Secret from Part 1
  3. BASE_URL: Your OAuth provider URL (e.g., https://oauth.mysite.com)
  4. REDIRECT_URL: Your website URL (e.g., https://www.mysite.com)
  5. Host in labels: Change oauth.mysite.com to 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:

  1. Wait 1-2 minutes for SSL certificate generation
  2. Check Traefik dashboard: https://traefik.yourvps.com
  3. 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/repository with your actual GitHub repo
  • Replace https://oauth.mysite.com with your actual OAuth provider URL
  • auth_endpoint: auth points to the /auth route 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

  1. Open your browser
  2. Go to: https://www.mysite.com/admin/
  3. You should see the Decap CMS interface
  4. 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

  1. Click “Login with GitHub” button
  2. Should redirect to oauth.mysite.com/auth
  3. Then redirects to GitHub
  4. You’ll see GitHub authorization page:
    • “Authorize [Your App Name]”
    • Shows permissions requested (repo, user)
  5. Click “Authorize [username]”
  6. Should redirect back to your CMS
  7. 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

  1. In the CMS, click on a collection (e.g., “Pages”)
  2. Click on an entry or create a new one
  3. Make a change (edit text, add content)
  4. Click “Publish” or “Save”
  5. Should see success message
  6. Check GitHub repository - should see a new commit!

Verify the commit:

  1. Go to your GitHub repo
  2. Check recent commits
  3. Should see a commit from your GitHub account
  4. Commit message will be from Decap CMS

Test 4: Verify Changes on Live Site

  1. Wait a few minutes (if using CI/CD)
  2. Visit your live website
  3. Changes should be visible
  4. 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:

  1. Check if admin files exist:
# On VPS
ls -la /opt/docker/websites/yoursite/html/admin/

Should see index.html and config.yml.

  1. Verify build includes admin files:
# Locally, after building
ls -la dist/admin/  # or build/admin/
  1. 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:

  1. Check config.yml syntax:
# Validate YAML
python3 -c "import yaml; yaml.safe_load(open('public/admin/config.yml'))"
  1. Verify config.yml is deployed:
# On VPS
cat /opt/docker/websites/yoursite/html/admin/config.yml
  1. Check browser console (F12 → Console tab):
  • Look for specific error messages
  • YAML parsing errors
  • 404 errors loading config
  1. 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:

  1. Check DNS resolves:
dig oauth.mysite.com +short
# Should return your VPS IP
  1. Check container is running:
ssh root@YOUR_VPS_IP
docker ps | grep decap-oauth
  1. Check container logs:
cd /opt/docker/decap-oauth
docker compose logs

Look for errors starting up.

  1. Test directly:
curl https://oauth.mysite.com/health

If this works, OAuth provider is running. Issue is with routing.

  1. Check Traefik configuration:
  • Visit: https://traefik.yourvps.com
  • Look for decap-oauth in HTTP Routers
  • Verify route is configured correctly
  1. 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:

  1. Verify Client ID and Secret:

Check docker-compose.yml has correct values:

cd /opt/docker/decap-oauth
cat docker-compose.yml | grep OAUTH_CLIENT
  1. 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
  1. 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.

  1. 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:

  1. 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
  1. 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.

  1. 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:

  1. Go to: https://github.com/organizations/YOUR_ORG/settings/oauth_application_policy
  2. Or: https://github.com/settings/connections/applications
  3. Find your OAuth App
  4. Grant organization access
  5. If you’re org owner, access grants immediately
  6. 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:

  1. 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
  1. Check branch name:

In config.yml:

backend:
  branch: main  # Must match actual branch name

If your default branch is master, change it to master.

  1. Check repository exists:

Verify the repo in config.yml is correct:

backend:
  repo: username/repository  # Check spelling!
  1. 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 -d fails
  • Container keeps restarting
  • docker ps shows container exiting

Solutions:

  1. Check logs:
docker compose logs

Look for specific error messages.

  1. 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

  1. Test server.js syntax:
node -c server.js

Should return nothing if syntax is valid.

  1. 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.yml only
  • 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:

  1. Go to: https://github.com/settings/developers
  2. Click your OAuth App
  3. Check Recent Delivery activity
  4. Look for unusual patterns

If compromised:

  1. Click “Revoke all user tokens”
  2. Generate new Client Secret
  3. Update docker-compose.yml
  4. Restart container
  5. 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:

  1. 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
}
  1. Rebuild container:
docker compose down
docker compose build --no-cache
docker compose up -d
  1. 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:

  1. Generate new secret on GitHub:
  • https://github.com/settings/developers
  • Find your OAuth App
  • “Generate a new client secret”
  • Copy new secret
  1. Update docker-compose.yml:
cd /opt/docker/decap-oauth
nano docker-compose.yml
# Update OAUTH_CLIENT_SECRET
  1. Restart container:
docker compose restart
  1. 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:

  • clients object holds multiple site configurations
  • Routes include :site parameter (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

  1. Wait ~2 minutes for GitHub Actions to deploy the config change
  2. Go to: https://www.boeygembos.be/admin/
  3. Click “Login with GitHub”
  4. 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:

  1. Create GitHub OAuth App
  2. Add site to server.js
  3. Add credentials to docker-compose.yml
  4. Restart container
  5. 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

  1. Go to: https://github.com/settings/developers
  2. Click New OAuth App
  3. 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)
  4. Click Register application
  5. Copy Client ID
  6. Click Generate a new client secret
  7. 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_url points to the shared OAuth provider
  • auth_endpoint includes the site name: auth/clientportfolio
  • Site name must match the key in server.js clients object

Commit, push, and deploy the site.

Step 6: Test New Site

  1. Go to: https://www.clientportfolio.com/admin/
  2. Click “Login with GitHub”
  3. Should redirect to GitHub
  4. Authorize
  5. 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 key
  • CREDENTIAL_TYPE = CLIENT_ID or CLIENT_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.js and auth_endpoint
  • Typo in site name
  • Forgot to restart container after adding site

Solutions:

  1. 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
  1. 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"
  1. 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:

  1. Verify credentials in GitHub:
  • Go to: https://github.com/settings/developers
  • Find your OAuth App
  • Check Client ID matches
  • If needed, regenerate Client Secret
  1. Update docker-compose.yml:
nano docker-compose.yml
# Update the credentials
docker compose down
docker compose up -d
  1. 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 allowedOrigins array
  • HTTP vs HTTPS mismatch
  • Wrong domain in allowedOrigins

Solutions:

  1. 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
  1. 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 -d fails
  • Container keeps restarting
  • Can’t access OAuth provider

Causes:

  • Syntax error in server.js
  • Missing dependencies
  • Port conflict

Solutions:

  1. Check logs for errors:
docker compose logs
  1. 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
  1. 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:

  1. Go to: https://github.com/organizations/ORG_NAME/settings/oauth_application_policy
  2. Find your OAuth App in the list
  3. Click Grant access or Request access
  4. If you’re org owner, access grants immediately
  5. 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:

  1. Check for duplicate keys:
cd /opt/docker/decap-oauth
grep -n "^  [a-z]*:" server.js

Each site should appear only once.

  1. Test configuration endpoint:
curl https://oauth.boeygembos.be/ | jq

Should list all sites. If it errors, there’s a syntax problem.

  1. 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

  1. Update server.js (e.g., change allowedOrigins)
nano /opt/docker/decap-oauth/server.js
# Make changes
  1. Restart container:
docker compose restart

No rebuild needed for config changes.

Remove a Site

  1. Delete from server.js:
// Remove the entire site object
  1. Remove from docker-compose.yml:
# Remove SITENAME_CLIENT_ID and SITENAME_CLIENT_SECRET
  1. Restart:
docker compose down
docker compose build --no-cache
docker compose up -d
  1. 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:

  1. 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
  1. Update docker-compose.yml:
nano docker-compose.yml
# Update SITENAME_CLIENT_SECRET
  1. Restart container:
docker compose restart
  1. 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 web network
  • 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:

  1. Enable caching (for very high traffic):
// Add to server.js
const NodeCache = require('node-cache');
const tokenCache = new NodeCache({ stdTTL: 600 }); // 10 min cache
  1. 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);
  1. 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:

  1. Inventory existing providers:
# List all OAuth containers
docker ps -a | grep oauth
  1. Document each site’s credentials:
  • GitHub OAuth App Client ID
  • GitHub OAuth App Client Secret
  • Site domain
  • Repository
  1. Set up multi-site provider (follow Initial Setup in this guide)
  2. Add all sites to multi-site config
  3. Update each site’s Decap CMS config:
base_url: https://oauth.boeygembos.be
auth_endpoint: auth/SITENAME
  1. Test each site individually
  2. Remove old single-site containers:
cd /opt/docker/old-oauth-provider
docker compose down
  1. Update DNS if needed (if using site-specific OAuth domains)

From Netlify/External OAuth

If migrating from Netlify or another OAuth service:

  1. Create GitHub OAuth Apps for each site
  2. Deploy multi-site provider on VPS
  3. Update Decap CMS configs to point to your provider
  4. Test thoroughly before removing old OAuth
  5. 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

  1. Overview
  2. Prerequisites
  3. Installation
  4. Reverse Proxy Configuration
  5. Initial Setup
  6. YubiKey MFA Configuration
  7. Integration with Applications
  8. 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:

  1. Create a new resource in Pangolin
  2. Configure:
    • Domain: auth.example.com
    • Target Address: authentik (container name)
    • Target Port: 9000
    • Authentication: Disabled (Authentik handles its own auth)
    • SSL/HTTPS: Enabled

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., admin or 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

  1. Click your username in the top right
  2. Select Settings

Step 2: Enroll First YubiKey

  1. Scroll to the MFA Devices section
  2. Click Enroll new device
  3. Select WebAuthn device (not TOTP)
    • WebAuthn/FIDO2 is the modern standard for hardware security keys
    • TOTP is for app-based authenticators like Google Authenticator
  4. Give it a friendly name (e.g., “YubiKey 5 - Primary”)
  5. Click Enroll
  6. Your browser will prompt you to insert and touch your YubiKey
  7. Touch the YubiKey when it blinks

Repeat the enrollment process for a second YubiKey:

  1. Enroll new device → WebAuthn device
  2. Name it (e.g., “YubiKey 5 - Backup”)
  3. 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!

  1. Navigate to Flows & Stages

  2. Find default-authentication-flow

  3. Click the ▶ (play/execute) icon to view stage bindings

  4. You’ll see stages:

    • Order 10: Identification
    • Order 20: Password
    • Order 30: MFA Validation
    • Order 100: User Login
  5. Click on default-authentication-mfa-validation stage (Order 30)

  6. Click Edit Stage (not Edit Binding)

  7. In the stage configuration:

    • Device classes: Ensure “WebAuthn” is selected
    • Other settings can remain default
  8. Click Update

  9. 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:

  1. Log out of Authentik
  2. Log back in with username and password
  3. You should be prompted for MFA
  4. Insert your YubiKey and touch it
  5. 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

  1. Navigate to Applications → Providers
  2. Click Create
  3. Select OAuth2/OpenID Provider
  4. 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
  5. Click Create
  6. Copy the Client ID and Client Secret - you’ll need these for your application

Step 2: Create Application

  1. Navigate to Applications → Applications
  2. Click Create
  3. Configure:
    • Name: Application name
    • Slug: app-name (lowercase, no spaces)
    • Provider: Select the provider you just created
  4. 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"}
  • If using containerized reverse proxy:
    • Ensure both are on the same Docker network
    • Use container name instead of localhost
    • Example: http://authentik:9000 instead of http://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_DOMAIN matches 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:

  1. Navigate to Flows & Stages
  2. Duplicate existing flows to create custom versions
  3. Add/remove stages to modify behavior
  4. Bind policies to stages for conditional logic

User Self-Service Registration

Enable users to create their own accounts:

  1. Navigate to Flows & Stages → Flows
  2. Find default-source-enrollment flow
  3. Click Edit
  4. Configure stages to collect user information
  5. Set Create users as inactive if you want admin approval
  6. Share enrollment URL: https://auth.example.com/if/flow/default-source-enrollment/

LDAP Support

Authentik can act as an LDAP server:

  1. Navigate to Applications → Outposts
  2. Create LDAP outpost
  3. Bind to users and groups
  4. Configure client applications to use Authentik LDAP

SAML Provider

For applications requiring SAML instead of OIDC:

  1. Navigate to Applications → Providers
  2. Create SAML Provider
  3. Configure metadata URL
  4. Set ACS URL and audience from your application

Security Best Practices

  1. Use strong passwords for PostgreSQL and Authentik admin
  2. Enable MFA for all users, especially administrators
  3. Enroll multiple YubiKeys for redundancy
  4. Regular backups of /opt/configs/authentik/database
  5. Update regularly: docker compose pull && docker compose up -d
  6. Monitor logs for suspicious activity
  7. Use separate networks for internal communication
  8. Limit outpost exposure - only expose necessary ports
  9. Review policies regularly to ensure proper access control
  10. 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
  • htpasswd available on the host (part of apache2-utils on Ubuntu/Debian, or httpd-tools on 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/data when 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

  1. Whitelist: Only approved usernames can connect
  2. AuthMe: Password required for each username
  3. 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

  1. Join the server with a test account (or ask someone to test)

  2. Try to register:

    /register testpass testpass
    
  3. 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

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:

  1. Are they whitelisted? whitelist list
  2. Are they registered? authme accounts PlayerName
  3. Is server online? list
  4. 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

  1. Parent requests access for their kid
  2. You pre-register: whitelist add Kid && authme register Kid pass
  3. Share credentials with parent (username + password + server address)
  4. Kid joins and logs in: /login password
  5. Record in password list for future reference
  6. 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

  1. ✅ Complete Steps 1-6 above
  2. ✅ Test with a dummy account
  3. ✅ Pre-register your own kids
  4. ✅ Create parent instructions document
  5. ✅ Share server info with trusted parents
  6. ✅ 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 — stores app.ini and custom templates
  • $DATA_PATH — stores repositories, LFS, SQLite database, logs

First Run: Install Wizard

Navigate to http://yourhost:7003 and configure:

FieldValue
Database typeSQLite3 (fine for personal use)
Repository root path/data/gitea/repositories
Server domaingit.yourdomain.com
SSH server port2020 (external/mapped port, used for clone URLs)
HTTP listen port3000 (internal container port)
Base URLhttps://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 the gerbil container, not Traefik directly. The homelab is reachable from the VPS via the WireGuard peer covering 192.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 && up rather than restart — 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

SymptomLikely cause
HTTPS clone redirects to Pangolin loginSet the Forgejo resource to public in Pangolin — Forgejo handles its own auth
SSH hangsPort not open in UFW or not exposed on Gerbil
SSH connection refusedTraefik not listening — check entrypoint in static config, restart with down && up
SSH connection resetWrong backend address in TCP service, or WireGuard tunnel not routing the port
manifest unknown on pullTag 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 .srm and .state files next to the ROM. Saves belong in /home/retro.
  • The cores mount must be rw. Anything inside /home/retro/.config or /home/retro/ES-DE is subject to the init script’s chown -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 is tg16, Master System is mastersystem, SG-1000 is sg-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

ThingWhat happensFix
NVIDIA driver bumpopaque driver/library version mismatchrebuild nvidia-driver-vol (section 3)
TrueNAS upgrade/etc/udev/rules.d wiped, gamepads stop workingPost Init script reapplies it; kernel_extra_options survives on its own
Config edits vanishWolf rewrites config.toml on shutdownstop the stack before editing
App keeps old mountsapp containers are built at launch timequit and relaunch the app, restarting Wolf is not enough
ES-DE lists games, launches failROM dirs r without x for UID 1000chmod -R a+rX on the library
Nothing launches, no coresimage ships RetroArch without coressection 6.2
App container fails at boot:ro mount inside /home/retro/.config blocks chown -Rmake it :rw
Moonlight finds nothingUDP 5353 held by TrueNASadd host manually with the port
ix-apps fills upimage pulls failcheck 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 a pg_ctlcluster-style versioned subdirectory under that mount point, so pointing the volume at the old .../data path 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-alpine as 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 .env tab, holds all persistent data (db, media, consume folder, etc.)
  • ${PAPERLESS_DB_NAME} / ${PAPERLESS_DB_USER} / ${PAPERLESS_DB_PASSWORD} — also from the Dockge .env tab, so the real password never sits in the compose file itself; used by both paperless-db and paperless-webserver, and must stay in sync since it’s the same credential on both sides
  • PAPERLESS_OCR_LANGUAGE=nld+eng — OCR runs both languages per document; PAPERLESS_OCR_LANGUAGES=nld triggers 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 the PUID/PGID convention used by LinuxServer images; Postgres and Redis manage their own internal permissions and shouldn’t get a forced user: directive, same reasoning as the Home Assistant case

First Run

  1. Bring the stack up in Dockge, wait for paperless-webserver to report healthy (first boot installs the Dutch OCR pack, so it takes a minute or two longer than usual)
  2. Create the admin user:
docker exec -it paperless-webserver python3 manage.py createsuperuser
  1. Log in at http://192.168.50.203:8504

NPM Reverse Proxy (local only)

In NPM, add a proxy host:

FieldValue
Domainpaperless.home.lan
Forward host192.168.50.203
Forward port8504
Websockets supportOn

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

SymptomLikely cause
OCR fails or produces gibberish on Dutch documentsPAPERLESS_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 NPMPAPERLESS_URL doesn’t match the hostname you’re browsing to
Office docs (docx/xlsx) fail to importGotenberg or Tika container isn’t healthy; check both are up before blaming the webserver
Consume folder not picking up filesPermission 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 .env file.


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_DEVICE must match the TrueNAS host NIC name. Verify with ip link show on the TrueNAS shell.


Step 4: Port Forward on Home Router

Forward UDP port 51821 to TrueNAS (192.168.50.203):

External PortProtocolInternal IPInternal Port
51821UDP192.168.50.20351821

Step 5: Add VPS as a Peer

  1. Open the wg-easy web UI: http://192.168.50.203:51822
  2. Log in with your password
  3. Click + New Client
  4. Name it hetzner-vps (or similar)
  5. Click the Download button next to the new peer to get its .conf file

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_DEVICE matches the actual NIC: ip link show on TrueNAS shell

Peer connects but homelab LAN unreachable:

  • Confirm WG_ALLOWED_IPS includes 192.168.50.0/24 in the wg-easy config
  • Verify IP forwarding is enabled on TrueNAS: sysctl net.ipv4.ip_forward (should be 1)

wg-easy web UI unreachable:

  • Check the container is running: docker ps | grep wg-easy
  • Confirm network_mode: host is set (required for kernel WireGuard interface access)

Password hash not accepted:

  • Ensure all $ characters in PASSWORD_HASH are doubled ($$) in the .env file

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

  1. Prerequisites
  2. Server Setup (VPS)
  3. Authentik SSO Integration
  4. Homelab Gateway Setup
  5. Client Setup
  6. 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:

  1. Domain: Enter your Netbird domain (e.g., netbird.example.com)
  2. Reverse proxy: Select [1] Existing Traefik
  3. External network: Enter your Traefik network name (e.g., web)
  4. HTTPS entrypoint: Enter your Traefik HTTPS entrypoint (usually websecure)
  5. 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 + Relay
  • netbird-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

  1. Navigate to https://netbird.example.com
  2. You should see the Netbird setup page
  3. Create the first admin account:
    • Name: Your name
    • Email: Your email
    • Password: Strong password (temporary - we’ll switch to SSO)
  4. 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

  1. Log into Authentik (https://auth.example.com)
  2. Navigate to Applications → Providers → Create
  3. Select OAuth2/OpenID Provider
  4. 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
  5. Click Create
  6. Copy the Client ID and Client Secret - you’ll need these

Step 2: Create Application in Authentik

  1. Navigate to Applications → Applications → Create
  2. Configure:
    • Name: Netbird
    • Slug: netbird
    • Provider: Select the Netbird provider you just created
  3. Click Create

Step 3: Add Authentik as Identity Provider in Netbird

  1. Log into Netbird dashboard (https://netbird.example.com)
  2. Navigate to Settings → Identity Providers
  3. Click Add Identity Provider
  4. Select Authentik
  5. 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
  6. Click Add Provider

Step 4: Switch to Authentik Authentication

  1. Log out of Netbird
  2. Log back in using Authentik
  3. After successful login, navigate to Settings → Users
  4. Transfer ownership from the local admin account to your Authentik account
  5. 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

  1. In Netbird dashboard, navigate to Setup Keys
  2. Click Add Setup Key
  3. 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)
  4. Click Create
  5. 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

  1. In Netbird dashboard, navigate to Network Routes
  2. Click Add Route
  3. 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)
  4. Click Create

Now any Netbird client can access your entire homelab network through this gateway!


Client Setup

Mobile (Android/iOS)

  1. Install Netbird from:
    • Android: Google Play Store or F-Droid
    • iOS: App Store
  2. Open the app
  3. Configure:
    • Management URL: https://netbird.example.com
  4. Click Login
  5. Authenticate via Authentik SSO
  6. 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)

  1. Download Netbird from netbird.io
  2. Install the .dmg package
  3. Open Netbird from Applications
  4. Configure:
    • Management URL: https://netbird.example.com
  5. Click Login
  6. Authenticate via Authentik
  7. Grant VPN permissions

Laptop (Windows)

  1. Download Netbird from netbird.io
  2. Run the installer
  3. Open Netbird from Start Menu
  4. Configure:
    • Management URL: https://netbird.example.com
  5. Click Login
  6. Authenticate via Authentik
  7. Grant VPN permissions

Troubleshooting

Server Issues

Dashboard not accessible:

  1. Check containers are running: docker ps | grep netbird
  2. Check Traefik routes are configured correctly
  3. Verify DNS points to your VPS IP
  4. Check Traefik logs: docker logs traefik

502 Bad Gateway:

  1. Ensure netbird-server and netbird-dashboard containers are running
  2. Verify they’re on the correct Docker network
  3. Check Traefik can reach the containers

OAuth errors during Authentik login:

  1. Verify redirect URIs match exactly in Authentik provider
  2. Check Client ID and Secret are correct
  3. Ensure Issuer URL ends with / (e.g., https://auth.example.com/application/o/netbird/)

Client Issues

Can’t connect to management server:

  1. Verify management URL: https://netbird.example.com
  2. Check firewall allows outbound HTTPS
  3. Ensure SSL certificate is valid

DNS broken after connecting (Linux):

  1. Disconnect: sudo netbird down
  2. Reconnect with DNS disabled: sudo netbird up --disable-dns
  3. Make it permanent using systemd override (see Linux client setup)

Can’t reach homelab:

  1. Verify network route exists in Netbird dashboard
  2. Check gateway peer is connected: netbird status
  3. Ping gateway peer: ping 100.86.236.236 (use actual gateway Netbird IP)
  4. 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

  1. Check route is enabled in Netbird dashboard
  2. Verify gateway peer is online
  3. Ensure gateway peer accepted the network route
  4. Check routing peer has proper network access
  5. Verify firewall on gateway allows traffic forwarding

Security Best Practices

  1. Use Authentik SSO: Centralized authentication with MFA support
  2. Enable MFA: Configure YubiKey or TOTP in Authentik
  3. Limit network routes: Only expose necessary subnets
  4. Use access control: Configure peer groups and policies in Netbird
  5. Monitor logs: Regularly check Netbird and Authentik logs
  6. Rotate setup keys: Set expiration dates on setup keys
  7. Review peer list: Regularly audit connected peers

Advanced Configuration

Multiple Network Routes

You can expose multiple networks:

  1. Create separate network routes for each subnet
  2. Assign different routing peers if needed
  3. Use distribution groups to control which clients can access which networks

Peer Groups and Policies

Control which peers can communicate:

  1. Create peer groups in Netbird dashboard
  2. Define access control policies
  3. Restrict peer-to-peer connections as needed

Custom DNS

Configure Netbird’s DNS for internal name resolution:

  1. Navigate to DNS in Netbird dashboard
  2. Add DNS nameservers
  3. Configure custom domains

High Availability

For production deployments:

  1. Run multiple Netbird server instances
  2. Use external PostgreSQL database
  3. Configure load balancer for management API
  4. 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/configs with your actual config path. The dns network is shared with Unbound — create it first if Unbound is already running, or create it manually with docker 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 blocking
  • IoT-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, or root-hints — these are managed internally by the image. The 172.16.0.0/12 access-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/unbound as used by other images.

Important: If Pi-hole uses host networking instead of the dns bridge network, remove the networks section from both the service and the bottom of the file, and ensure access-control: 172.16.0.0/12 allow is 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

SymptomCauseFix
Container exits immediatelyConfig syntax errorCheck docker logs unbound; fix syntax in unbound.conf
REFUSED responsesIP not in access-controlAdd your subnet to access-control in config
Slow browsing on first visitCold cache recursive lookupNormal — warms up with use; serve-expired helps
Very slow YouTube/streamingDynamic CDN subdomainsAdd forward zones for googlevideo.com etc.
Pi-hole not forwarding to UnboundWrong upstream IP or portVerify upstream is set to correct IP/port in Pi-hole; check access-control includes Docker subnet
docker exec commands failDistroless image — no shellUse dig from the host instead
Container shows unhealthyBuilt-in healthcheck uses internal toolingAdd 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

  1. Log in to Hetzner Console
  2. Navigate to Storage Boxes in the sidebar
  3. 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 :23 in the URI
  • The path /backup-path will 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_keys on 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_IP with your VPS IP address (preferably WireGuard IP like 10.8.0.1)
  • Replace /mnt/pool with your actual TrueNAS pool mount point
  • Adjust ${CONFIG_PATH} if needed (or use absolute paths)
  • Adjust TZ to your timezone

Step 2: Deploy the Stack

  1. In Dockge, click Deploy
  2. Wait for the container to start
  3. 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

  1. Navigate to Repositories → + Add Repo

  2. 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 uXXXXXX with your Storage Box username
      • The /tnt-backup path will be created automatically

    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)
  3. Click Test Configuration

    • Should show success
  4. Click Submit

    • Repository will be initialized

Part 5: Create Backup Plans

Homelab Backup Plan

  1. Navigate to Plans → + Add Plan

  2. 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
      • *.tmp
      • cache/
      • .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
  3. Click Submit

VPS Backup Plan

  1. Navigate to Plans → + Add Plan

  2. 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
      • *.tmp
      • cache/
      • .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
  3. Click Submit

Using Healthchecks.io

  1. Create account at Healthchecks.io

  2. Create a check for each backup plan:

    • Name: Homelab Backup
    • Schedule: Weekly
    • Grace Time: 2 hours
  3. 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
  4. 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

  1. In Backrest UI, navigate to Plans
  2. Click on homelab-backup
  3. Click Backup Now
  4. Monitor the progress in the UI
  5. Verify backup completes successfully

Repeat for vps-backup.

Test Restore

  1. Navigate to Plans → Select a plan
  2. Click on a completed backup snapshot
  3. Browse files in the backup
  4. Select a file and click Restore
  5. Verify file is restored

Maintenance

Regular Checks

  • Weekly: Check backup logs for successful completion
  • Monthly: Run restic check via Backrest to verify repository integrity
  • Quarterly: Test a full restore operation

Monitoring Storage Usage

  1. Check Storage Box usage in Hetzner Console
  2. Monitor disk graphs in Backrest dashboard
  3. Adjust retention policy if space becomes constrained

Updating Backrest

When new versions are released:

  1. In Dockge, update image tag or pull latest
  2. Redeploy the stack
  3. 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

  1. SSH Keys: Keep private keys secure, use different keys per machine
  2. Encryption Password: Store Restic password in password manager
  3. Network: Backrest UI has authentication enabled
  4. Updates: Keep Backrest and base images updated
  5. Access: Limit who can access Backrest web UI

Performance Tips

  1. Exclude Large Files: Don’t back up media files unless necessary
  2. Adjust Schedules: Space out backup times to reduce load
  3. Retention: Balance between backup history and storage costs
  4. 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:

  1. ✅ Test backups run successfully
  2. ✅ Verify notifications work
  3. ✅ Document your Restic encryption password securely
  4. ✅ Test a restore operation
  5. ✅ Set calendar reminders for quarterly restore tests

Additional Resources