Software & packages

Image manipulation

Compression

Install sudo apt-get install imagemagick

Compress command mogrify -quality 80% *.jpg

note: this will overwrite images

yt-dlp

Download video as mp3

yt-dlp -x --audio-format mp3 URL_HERE

Convert webm to mp4

 ffmpeg -i input.webm -c:v libx264 -preset slow -crf 22 -c:a aac -b:a 128k output.mp4

Download video as mp4

 yt-dlp -f "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best" SOURCE_URL

Convert video codec to x264

 ffmpeg -i input.mp4 -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 192k output.mp4


Python packages and commands

Managing Virtual Envrionments

Create a new venv python3 -m venv /path/to/new/virtual/environment

Activate the new venv Linux / Mac source myenv/bin/activate

Activate an envrionment on windows myenv\Scripts\activate

Exit the venv deactivate

 

Installing and uninstalling packages

Installing a package pip install xyz

Uninstalling a package pip uninstall xyz

 

Useful packages

 

 

 

 

spotdl

Install & Setup

Best to load a venv first

Installing spotdl pip install spotdl

 Open the config file at .spotdl/config.json and update "output" to be "{album}/{title}.{output-ext}"

 

Updating

pip install --upgrade spotdl

 

 

 

 

 

ffmpeg

Installing

sudo apt install ffmpeg

Compress a video to h265

 ffmpeg -i example_h264.mp4 -c:v libx265 -crf 24 -preset fast -c:a aac -b:a 128k example_h265.mp4

CRF Info

For H.264 (x264): 

The typical CRF range is 18-28. 

CRF 18: Near lossless quality (very high quality, larger file size). 

CRF 23: Default value, good balance between quality and file size. 

CRF 28: Noticeable quality loss, much smaller file size.


Overwright a videos audio with a new audio track

 ffmpeg -i video_to_overwright.mkv -i audio_input.mp3 -c:v copy -map 0:v:0 -map 1:a:0 output.mp4

MP4 to MP3

 ffmpeg -i filename.mp4 filename.mp3

 

Change video format

ffmpeg -i input.m4v -c copy output.mp4

 

IPFS

Installation for Linux

1 - Download the package

 wget https://dist.ipfs.tech/kubo/v0.31.0/kubo_v0.31.0_linux-amd64.tar.gz

2 - Unzip the file

 tar -xvzf kubo_v0.39.0_linux-amd64.tar.gz

3 - Move into the kubo folder

 cd kubo

4 - Run the install script

 sudo bash install.sh

5 - Test the installation worked

 ipfs --version

Editing the config

Show Config:

 ipfs config show

Change connection count

ipfs config Swarm.ConnMgr.HighWater 1000 --json
ipfs config Swarm.ConnMgr.LowWater 500 --json

Enable filestore (Allow import with no copy)

ipfs config --json Experimental.FilestoreEnabled true
sudo systemctl restart ipfs
ipfs config Experimental.FilestoreEnabled

IPFS as a service

Open a new file at /etc/systemd/system/ipfs.service

[Unit]
Description=IPFS daemon
After=network.target

[Service]
ExecStart=/usr/local/bin/ipfs daemon
Restart=on-failure
User=conor
Group=conor
Environment=IPFS_PATH=/home/conor/.ipfs

[Install]
WantedBy=default.target

WantedBy=default.target means the service will start up at boot

get it to start at boot

 sudo systemctl enable ipfs

and start it

 sudo systemctl start ipfs

Usage

Check filestore is enabled and working

ipfs config Experimental.FilestoreEnabled

List files in filestore

ipfs filestore ls

Import files to filestore

ipfs add --nocopy --recursive --cid-version=1 /path/to/your/folder

Wireguard

Setup Script

#!/bin/bash

# Checks to see if script is being run as root
if [ "$EUID" -ne 0 ]; then
  echo "Please run as root"
  exit
fi

sudo apt install wireguard

# Deletes keys in case this script is being run again
rm -f /etc/wireguard/* 

# Create necessary directories, including parents
mkdir -p /etc/wireguard/client


# Create and store server private and public key
wg genkey | tee /etc/wireguard/server_priv.key | wg pubkey | tee /etc/wireguard/server_pub.key

# Get the server public and private key as well as the network interface
server_priv_key=$(cat /etc/wireguard/server_priv.key)
server_pub_key=$(cat /etc/wireguard/server_pub.key)
network_interface=$(ip -o -4 route show to default | awk '{print $5}')


# Client
# Create client public and private key and store it in vars for later
wg genkey | tee /etc/wireguard/client/client_priv.key | wg pubkey | tee /etc/wireguard/client/client_pub.key
client_priv_key=$(cat /etc/wireguard/client/client_priv.key)
client_pub_key=$(cat /etc/wireguard/client/client_pub.key)

# Create initial client config
echo "[Interface]
PrivateKey = $client_priv_key
Address = 10.0.0.2/24

[Peer]
PublicKey = $server_pub_key
Endpoint = servername_or_ip:51820
AllowedIPs = 0.0.0.0/0
" > /etc/wireguard/client/wg0.conf


# Create the config file for wireguard
echo "[Interface]
Address = 10.0.0.1/24
SaveConfig = true
ListenPort = 51820
PrivateKey = $server_priv_key
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o $network_interface -j MASQUERADE
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o $network_interface -j MASQUERADE

[Peer]
PublicKey = $client_pub_key
AllowedIPs = 10.0.0.2/24
" > /etc/wireguard/wg0.conf

# Change permissions so only root can access the files
chmod 600 /etc/wireguard/server_priv.key
chmod 600 /etc/wireguard/wg0.conf

# Allow 51820 through ufw
ufw allow 51820/udp

# Start wireguard and set it to auto start on boot
wg-quick up wg0
systemctl enable wg-quick@wg0



Setup - Manual

1 - Update system and install wireguard

 apt update && apt upgrade && apt install wireguard

2 - Make the necessary folders 

 mkdir -p /etc/wireguard/client

3 - Create the server's public and private keys 

 wg genkey | tee /etc/wireguard/server_priv.key | wg pubkey | tee /etc/wireguard/server_pub.key

4 - Find the servers network interface

 ip -o -4 route show to default | awk '{print $5}

5 - Nano into 

 /etc/wireguard/wg0.conf 

(Make sure to add network interface and server private key) 

 [Interface] Address = 10.0.0.1/24 
SaveConfig = true 
ListenPort = 51820 
PrivateKey = SERVER_PRIVATE_KEY 
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o NETWORK_INTERFACE_HERE -j MASQUERADE 
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o NETWORK_INTERFACE_HERE -j MASQUERADE

 6 - Update file permissions so only root can read the config file and private key 

 chmod 600 /etc/wireguard/server_priv.key chmod 600 /etc/wireguard/wg0.conf

 7 - Allow the vpn port through UFW

 ufw allow 51820/udp

 8 - Start wireguard and set it to auto start at boot 

 wg-quick up wg0 systemctl enable wg-quick@wg0

 9 - Create the client keys 

 wg genkey | tee /etc/wireguard/client/priv.key | wg pubkey | tee /etc/wireguard/client/pub.key

 10 - Create the client config file (copy to client device) 

[Interface] PrivateKey = CLIENT_PRIVATE_KEY 
Address = 10.0.0.2/24 [Peer] 
PublicKey = SERVER_PUBLIC_KEY 
Endpoint = SERVER_IP_OR_DOMAIN:51820 
AllowedIPs = 0.0.0.0/0

11 - Add peer info to the server config

[Interface] Address = 10.0.0.1/24 
SaveConfig = true 
ListenPort = 51820 
PrivateKey = SERVER_PRIVATE_KEY 
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o NETWORK_INTERFACE_HERE -j MASQUERADE 
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o NETWORK_INTERFACE_HERE -j MASQUERADE

# =================
[Peer]
PublicKey = PUBLIC_KEY
AllowedIPs = 10.0.0.2/24

Setup with SeaBee's setup script

wget https://raw.githubusercontent.com/seabee33/wireguard_helper/refs/heads/main/wg_helper.py && chmod +x wg_helper.py && sudo python3 wg_helper.py

 

Auto start at boot

1 - ensure the client config is at /etc/wireguard/wg0.conf
2 - enable it to start at boot with sudo systemctl enable wg-quick@wg0

 

 

Node & NVM

1 - install node version manager (NVM)

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash

2 - install node

nvm install node


General steps

1 - install components

npm create vite@latest my-charting-site --template react
cd my-charting-site
npm install

npm install -D tailwindcss@3 postcss autoprefixer
npx tailwindcss init -p

2 - test

npm run dev

 

build

npm run build

 

Syncthing

Screen

Screen lets you make virtual terminals with

screen

 

 

New session with name

screen -S session_name

 

Tailscale

Client setup

1. Install Tailscale client

curl -fsSL https://tailscale.com/install.sh | sh

2. Create a pre-auth key on your Headscale server

docker exec headscale headscale preauthkeys create --user 1 --reusable --expiration 24h

Copy the key that's generated.

3. Connect to your Headscale server

sudo tailscale up --login-server=https://headscale.seabee.me --authkey=YOUR_PREAUTH_KEY --hostname=rpi3

Replace YOUR_PREAUTH_KEY with the key from step 2.

4. Verify connection

# On Raspberry Pi:
sudo tailscale status

# On your Headscale server:
docker exec headscale headscale nodes list

You should see your Raspberry Pi listed with the hostname "raspberry-pi" and an IP like 100.64.0.2.

smb / samba

Samba Setup on Linux (Home Server)

A practical guide to installing and configuring Samba for a home network, covering a shared folder setup suitable for a home wiki or general file server.


1. Install Samba

sudo apt update && sudo apt install samba samba-common-bin -y

Verify it's running:

sudo systemctl status smbd nmbd

Both should be active. If not:

sudo systemctl enable smbd nmbd --now

2. Create the Share Directory

sudo mkdir -p /srv/samba/wiki
sudo chown -R conor:conor /srv/samba/wiki
sudo chmod 755 /srv/samba/wiki

Adjust owner to your actual Linux username.


3. Configure Samba

Back up the default config first:

sudo cp /etc/samba/smb.conf /etc/samba/smb.conf.bak

Edit the config:

sudo nano /etc/samba/smb.conf

Global section — replace or update:

[global]
   workgroup = WORKGROUP
   server string = Home Server
   server role = standalone server
   log file = /var/log/samba/log.%m
   max log size = 1000
   logging = file
   panic action = /usr/share/samba/panic-action %d

   # Security
   security = user
   map to guest = never
   encrypt passwords = yes

   # Performance
   socket options = TCP_NODELAY IPTOS_LOWDELAY
   read raw = yes
   write raw = yes
   use sendfile = yes

Share definition — add to the bottom:

[wiki]
   comment = Home Wiki
   path = /srv/samba/wiki
   browseable = yes
   read only = no
   valid users = conor
   create mask = 0664
   directory mask = 0775
   force group = conor

4. Create Samba User

Samba uses its own password store separate from Linux system passwords. The Linux user must already exist.

sudo smbpasswd -a conor

You'll be prompted to set a Samba-specific password. Enable the user:

sudo smbpasswd -e conor

5. Validate Config and Restart

Test the config for syntax errors:

testparm

If clean:

sudo systemctl restart smbd nmbd

6. Firewall

If UFW is active:

sudo ufw allow samba

This opens ports 137, 138 (UDP) and 139, 445 (TCP).


7. Connect From Clients

Linux (Files / Nautilus):

smb://SERVER_IP/wiki

Or mount via CLI:

sudo mount -t cifs //SERVER_IP/wiki /mnt/wiki -o username=conor,password=yourpass,uid=1000,gid=1000

Permanent via /etc/fstab:

//SERVER_IP/wiki  /mnt/wiki  cifs  credentials=/home/conor/.smbcredentials,uid=1000,gid=1000,_netdev,x-systemd.automount  0  0

/home/conor/.smbcredentials:

username=conor
password=yourpass
chmod 600 /home/conor/.smbcredentials

macOS:

Finder → Go → Connect to Server → smb://SERVER_IP/wiki

Windows:

\\SERVER_IP\wiki in Explorer address bar, or map as a network drive.


8. Tailscale Consideration

Since you're on Tailscale, use the Tailscale IP (100.x.x.x) instead of the LAN IP for consistent access across all your machines regardless of which network you're on. This also avoids exposing Samba to the public internet — Samba should never be exposed publicly, it has a long CVE history (EternalBlue, etc.).

Bind Samba only to your LAN + Tailscale interfaces to be safe:

[global]
   interfaces = lo tailscale0 eth0
   bind interfaces only = yes

Replace eth0 with your actual LAN interface (ip a to check).


9. Verify the Share is Visible

From the server itself:

smbclient -L localhost -U conor

From another machine:

smbclient -L //SERVER_IP -U conor

You should see wiki listed under shares.


10. Troubleshooting

Symptom Likely cause Fix
"Permission denied" on connect Wrong Samba password or user not enabled smbpasswd -e username
Share not visible browseable = no or firewall Check config + ufw status
Can connect but can't write Directory permissions chmod 775 /srv/samba/wiki
Works on LAN, not Tailscale Interfaces binding Add tailscale0 to interfaces
testparm errors Config syntax Read the output carefully, it's specific

Notes

autofs

autofs Setup and Configuration Guide

autofs mounts filesystems on-demand when accessed and unmounts them after a configurable idle timeout. This makes it significantly more robust than static /etc/fstab mounts for network shares — a missing or offline host won't hang your boot, and stale mounts are cleaned up automatically.


1. Install

sudo apt update && sudo apt install autofs sshfs -y

For NFS shares also install:

sudo apt install nfs-common -y

2. How autofs Works

autofs has two config layers:

File Purpose
/etc/auto.master Master map — defines mount point directories and which map file handles them
/etc/auto.<name> Detail map — defines individual mounts under that directory

When you access /mnt/remote/myshare, autofs intercepts it, reads the map, and mounts the share on the fly. After the timeout with no activity, it unmounts automatically.


3. Master Map — /etc/auto.master

Each line in auto.master follows this format:

<base_mount_dir>  <map_file>  [options]

Example:

/mnt/remote  /etc/auto.sshfs  --timeout=60,--ghost
/mnt/nfs     /etc/auto.nfs    --timeout=120,--ghost

Key options:

Option Effect
--timeout=N Unmount after N seconds of inactivity
--ghost Creates empty placeholder directories so the mount point is visible even when unmounted. Without this, ls /mnt/remote shows nothing until a mount is active
--verbose Useful during setup/debugging

Important: auto.master entry placement

The default /etc/auto.master contains a +auto.master line which is a legacy NIS include directive. Your custom entries must go above this line, otherwise they may be ignored on some systems. The +auto.master line is safe to remove entirely on a standalone home server with no NIS infrastructure.

Clean minimal auto.master:

+dir:/etc/auto.master.d

/mnt/remote  /etc/auto.sshfs  --timeout=60,--ghost

The +dir:/etc/auto.master.d line is worth keeping — it allows dropping extra .autofs map files into that directory without editing auto.master directly.


4. Detail Map Files

SSHFS (SSH/SFTP) — Modern Syntax

Important: Many guides online show legacy syntax using fstype=fuse and :sshfs#user@host format. This does not work on modern Linux kernels. Always use fstype=fuse.sshfs with plain user@host:/path syntax.

Create /etc/auto.sshfs:

<mount_name>  -fstype=fuse.sshfs,rw,allow_other,IdentityFile=/home/conor/.ssh/id_ed25519,uid=1000,gid=1000,StrictHostKeyChecking=no,ServerAliveInterval=15,ServerAliveCountMax=3  conor@100.x.x.x:/home/conor

Breakdown:

Part Meaning
<mount_name> The subdirectory created under the base — e.g. farmdesktop becomes /mnt/remote/farmdesktop
-fstype=fuse.sshfs Correct modern fstype for SSHFS mounts
rw Read/write
allow_other Allows users other than root to access the mount
IdentityFile= Path to SSH private key — must use key auth, not password
uid=1000,gid=1000 Map remote files to your local user. Check yours with id conor
StrictHostKeyChecking=no Required because autofs runs as root which has an empty known_hosts — without this the mount hangs waiting for an interactive host key confirmation that never comes
ServerAliveInterval=15 Send keepalive every 15s to prevent silent disconnects
ServerAliveCountMax=3 Drop connection after 3 missed keepalives

Full working example:

farm-server  -fstype=fuse.sshfs,rw,allow_other,IdentityFile=/home/conor/.ssh/cbcore-key,uid=1000,gid=1000,StrictHostKeyChecking=no,ServerAliveInterval=15,ServerAliveCountMax=3  conor@100.64.0.12:/home/conor

Access via: /mnt/remote/farm-server

Why legacy syntax fails

Old guides show:

farmdesktop  -fstype=fuse,rw,allow_other,...  :sshfs#conor@100.64.0.5:/home/conor

This tells autofs to call mount -t fuse with sshfs# as the device — an approach that relied on the generic FUSE kernel module handling the mount. Modern kernels expect mount -t fuse.sshfs with a plain remote path. Using the old syntax results in signal 22 (SIGABRT) and a failed mount with no useful error message.


NFS

Create /etc/auto.nfs:

nas  -fstype=nfs4,rw,soft,intr  100.64.0.10:/exports/data
Option Meaning
nfs4 Use NFSv4 (preferred)
soft Return error on timeout instead of hanging indefinitely
intr Allow interrupting a hung NFS call with Ctrl+C

Samba / CIFS

Create /etc/auto.smb:

wiki  -fstype=cifs,rw,credentials=/home/conor/.smbcredentials,uid=1000,gid=1000,iocharset=utf8  ://100.64.0.10/wiki

/home/conor/.smbcredentials:

username=conor
password=yourpassword
chmod 600 /home/conor/.smbcredentials

5. FUSE Prerequisite for SSHFS

allow_other requires this line to be uncommented in /etc/fuse.conf:

sudo nano /etc/fuse.conf

Uncomment:

user_allow_other

Without this, SSHFS mounts via autofs (which runs as root) will be inaccessible to your user account.


6. SSH Key Setup

autofs runs as root, so two things must be true:

  1. The key file must be readable by root
  2. Root must have the remote host in its known_hosts — or StrictHostKeyChecking=no must be set

Seed root's known_hosts before first use:

sudo ssh -i /home/conor/.ssh/your-key conor@100.x.x.x echo "ok"
# Type "yes" when prompted to accept the host key

Alternatively, use StrictHostKeyChecking=no in the map file options (acceptable on a private Tailscale network).

Option A — use your existing user key (works if permissions allow root to read it):

IdentityFile=/home/conor/.ssh/id_ed25519

Option B — create a dedicated root-owned key for autofs:

sudo ssh-keygen -t ed25519 -f /root/.ssh/autofs_id_ed25519 -N ""
sudo ssh-copy-id -i /root/.ssh/autofs_id_ed25519.pub conor@100.x.x.x

Then use IdentityFile=/root/.ssh/autofs_id_ed25519 in the map file. Cleaner — keeps autofs auth separate from your interactive SSH key.


7. Enable and Start autofs

sudo systemctl enable autofs --now

Reload after map file changes:

sudo systemctl reload autofs

Full restart required after auto.master changes:

sudo systemctl restart autofs

8. Testing

Trigger a mount by accessing the path directly:

ls /mnt/remote/farm-server

Check what's currently mounted:

mount | grep autofs

Verify autofs has loaded your maps correctly:

sudo automount --dumpmaps

Check status and logs:

sudo systemctl status autofs
sudo journalctl -u autofs -f

Force unmount manually:

sudo umount /mnt/remote/farm-server

9. Debugging a Failed Mount

If the mount silently fails, run autofs in foreground debug mode:

sudo systemctl stop autofs
sudo automount -f --verbose --debug 2>&1 | tee /tmp/autofs-debug.log &
sleep 2
ls /mnt/remote/farm-server
sleep 2
cat /tmp/autofs-debug.log

Then kill the debug instance and restart the service:

sudo pkill -9 -f automount
sudo rm -f /run/autofs.pid
sudo systemctl start autofs

If the mount command itself hangs, test it manually first:

# Test SSH connectivity as root
sudo ssh -i /path/to/key user@host echo "ok"

# Test sshfs as your normal user
mkdir /tmp/testmount
sshfs -o IdentityFile=/path/to/key user@host:/remote/path /tmp/testmount
ls /tmp/testmount

If sshfs works as your user but hangs as root, the issue is root's SSH environment (known_hosts, key permissions). Add StrictHostKeyChecking=no to the map options and ensure the key is readable by root.


10. Troubleshooting

Symptom Likely cause Fix
ls on mount point returns "No such file or directory" autofs not reading map, or entry placement in auto.master Run sudo automount --dumpmaps to verify map is loaded; check entry is above +auto.master
Mount hangs on access Root's known_hosts empty — SSH waiting for interactive host key confirmation Add StrictHostKeyChecking=no to map options, or pre-seed with sudo ssh user@host echo ok
signal 22 / SIGABRT in debug output Legacy fstype=fuse + sshfs# syntax Change to fstype=fuse.sshfs and plain user@host:/path
"Permission denied" accessing mounted path allow_other not set or user_allow_other not in fuse.conf Check /etc/fuse.conf
Mount works as root but not as user uid= / gid= not set in map file Add uid=1000,gid=1000 (check with id username)
autofs fails to start after manual debug session Stale PID file from debug automount process sudo pkill -9 -f automount && sudo rm -f /run/autofs.pid
Changes to map file have no effect autofs cached old config sudo systemctl reload autofs
ls /mnt/remote shows nothing despite ghost mode Ghost mode requires autofs to be fully running and map parsed Run sudo automount --dumpmaps to confirm map loaded

11. Full Example: Tailscale Homelab

/etc/auto.master:

+dir:/etc/auto.master.d

/mnt/remote  /etc/auto.sshfs  --timeout=60,--ghost

/etc/auto.sshfs:

farm-pi      -fstype=fuse.sshfs,rw,allow_other,IdentityFile=/root/.ssh/autofs_id_ed25519,uid=1000,gid=1000,StrictHostKeyChecking=no,ServerAliveInterval=15,ServerAliveCountMax=3  conor@100.64.0.2:/home/conor
farmdesktop  -fstype=fuse.sshfs,rw,allow_other,IdentityFile=/root/.ssh/autofs_id_ed25519,uid=1000,gid=1000,StrictHostKeyChecking=no,ServerAliveInterval=15,ServerAliveCountMax=3  conor@100.64.0.3:/home/conor
homeserver   -fstype=fuse.sshfs,rw,allow_other,IdentityFile=/root/.ssh/autofs_id_ed25519,uid=1000,gid=1000,StrictHostKeyChecking=no,ServerAliveInterval=15,ServerAliveCountMax=3  conor@100.64.0.4:/home/conor

Access:

/mnt/remote/farm-pi/
/mnt/remote/farmdesktop/
/mnt/remote/homeserver/

All mount on first access, unmount after 60 seconds idle. If a farm machine is offline, accessing its path returns an error immediately rather than hanging the system.


Notes

Headscale

This supersedes the original guide. Changes from that version, all based on what you actually have running:

  1. Caddy is a native systemd service on the host, not a Docker container — the original guide's caddy service block in docker-compose.yml and the Docker-network Caddyfile no longer apply.
  2. server_url had the internal port (:8080) wrongly appended — fixed.
  3. trusted_proxies was missing entirely — added, using 127.0.0.1/32 (correct because Caddy reaches Headscale via host loopback, not a Docker bridge network — that CIDR would only have been right if Caddy were a container in the same Compose stack, which it isn't).
  4. dns.base_domain was left as the placeholder example.com — fixed to a subdomain distinct from server_url's domain, per Headscale's own requirement that these must differ.

Version basis: this is checked against Headscale's current main-branch config-example.yaml and the official reverse-proxy docs, consistent with the v0.28.0 pin from the original compose file. I don't have confirmation of which version is actually running in your container — run docker exec headscale headscale version and tell me if it's different, since config field names have moved between sections in past releases.


0. DNS — what actually needs a real domain record

Only one public DNS record is needed: an A (and AAAA, if your VPS has IPv6) record for headscale.seabee.me, pointing at the VPS's public IP. This is the domain Caddy serves over real HTTPS, the one Let's Encrypt validates via HTTP-01, and the one used in server_url.

ts.seabee.me (the dns.base_domain value) needs no DNS record at all. It's never queried against public DNS or your registrar — it's a string Headscale uses internally to construct MagicDNS device names (hostname.ts.seabee.me), resolved only by Headscale's own embedded DNS server, which Tailscale clients query via 100.100.100.100. It doesn't need to exist anywhere except in this config file.

It still has to be a domain you own and aren't using for anything else, though — not because it needs to resolve anywhere, but because if you pick a domain you're actively using for real services, MagicDNS will hijack every query under that domain for any device on the tailnet and return NXDOMAIN instead of the real record, confirmed by a filed upstream Headscale bug report showing exactly this happening to someone's real subdomain. Since ts.seabee.me isn't serving anything else, this doesn't affect you — just don't reuse it for a real service later without remembering why.

In the Caddyfile (Section 4 below), only headscale.seabee.me appears — that's correct and intentional. ts.seabee.me never touches Caddy.


1. Folder structure

You already created this. The caddy/ subfolders (caddy/data, caddy/config) from the original mkdir are no longer needed since Caddy isn't running in this stack — leave them (harmless) or rm -rf caddy/ from ~/Docker/headscale/, your call.

Structure actually in use now:

~/Docker/headscale/
├── docker-compose.yml
├── headscale/
│   ├── config/config.yaml
│   ├── lib/
│   └── run/
└── headplane/
    ├── config/config.yaml
    └── lib/

2. headscale/config/config.yaml — corrected

Edit your existing file. The specific lines to change:

# WAS: server_url: https://headscale.seabee.me:8080
server_url: https://headscale.seabee.me

listen_addr: 0.0.0.0:8080          # correct as-is — Docker needs this bound inside the container
metrics_listen_addr: 127.0.0.1:9090
grpc_listen_addr: 127.0.0.1:50443
grpc_allow_insecure: false

# ADD THIS — was missing entirely:
trusted_proxies:
  - 127.0.0.1/32
  - ::1/128

noise:
  private_key_path: /var/lib/headscale/noise_private.key
prefixes:
  v4: 100.64.0.0/10
  v6: fd7a:115c:a1e0::/48
  allocation: sequential
derp:
  server:
    enabled: false
    region_id: 999
    region_code: "headscale"
    region_name: "Headscale Embedded DERP"
    verify_clients: true
    stun_listen_addr: "0.0.0.0:3478"
    private_key_path: /var/lib/headscale/derp_server_private.key
    automatically_add_embedded_derp_region: true
    ipv4: 198.51.100.1
    ipv6: 2001:db8::1
  urls:
    - https://controlplane.tailscale.com/derpmap/default
  paths: []
  auto_update_enabled: true
  update_frequency: 3h
disable_check_updates: false
ephemeral_node_inactivity_timeout: 30m
database:
  type: sqlite
  debug: false
  gorm:
    prepare_stmt: true
    parameterized_queries: true
    skip_err_record_not_found: true
    slow_threshold: 1000
  sqlite:
    path: /var/lib/headscale/db.sqlite
    write_ahead_log: true
    wal_autocheckpoint: 1000
acme_url: https://acme-v02.api.letsencrypt.org/directory
acme_email: ""
tls_letsencrypt_hostname: ""
tls_letsencrypt_cache_dir: /var/lib/headscale/cache
tls_letsencrypt_challenge_type: HTTP-01
tls_letsencrypt_listen: ":http"
tls_cert_path: ""
tls_key_path: ""
log:
  level: info
  format: text
policy:
  mode: file
  path: ""
dns:
  magic_dns: true
  # WAS: base_domain: example.com  — the untouched placeholder, and it must differ from server_url's domain
  base_domain: ts.seabee.me
  override_local_dns: true
  nameservers:
    global:
      - 1.1.1.1
      - 1.0.0.1
      - 2606:4700:4700::1111
      - 2606:4700:4700::1001
    split: {}
  search_domains: []
  extra_records: []
unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"
logtail:
  enabled: false
randomize_client_port: false
taildrop:
  enabled: true

base_domain: ts.seabee.me is a placeholder suggestion — any subdomain of seabee.me other than headscale.seabee.me (which server_url already uses) works. Pick whatever you actually want your devices' MagicDNS names to end in.


3. docker-compose.yml — drop the Caddy service

services:
  headscale:
    image: headscale/headscale:0.28.0
    container_name: headscale
    restart: unless-stopped
    command: serve
    volumes:
      - ./headscale/config:/etc/headscale
      - ./headscale/lib:/var/lib/headscale
      - ./headscale/run:/var/run/headscale
    ports:
      - "127.0.0.1:8080:8080"
      - "127.0.0.1:9090:9090"
    networks:
      - headscale-net

  headplane:
    image: ghcr.io/tale/headplane:v0.6.1
    container_name: headplane
    restart: unless-stopped
    ports:
      - "127.0.0.1:3000:3000"
    volumes:
      - ./headplane/config/config.yaml:/etc/headplane/config.yaml
      - ./headplane/lib:/var/lib/headplane
      - ./headscale/config/config.yaml:/etc/headscale/config.yaml
      - /var/run/docker.sock:/var/run/docker.sock:ro
    depends_on:
      - headscale
    networks:
      - headscale-net

networks:
  headscale-net:
    driver: bridge

Pin the Headplane tag to whatever's actually current at https://github.com/tale/headplane/releases — v0.6.1 here is a placeholder, not a verified-current release.

If your existing compose file still has a caddy: block from the original guide, delete it — running two things bound to host port 443/80 (native Caddy plus a would-be Caddy container) will conflict, and you don't want the container one anyway now.


4. Native Caddy config

I don't have confirmation of your VPS's distro or how Caddy was installed, so I can't state the config path or reload command as fact. If it's the official Caddy apt repo on Debian/Ubuntu, the convention is /etc/caddy/Caddyfile, reloaded with sudo systemctl reload caddy — verify this against your actual install before running it, since a manually-built binary, snap package, or a different distro's package manager may differ.

Caddyfile content (path-independent, this part is safe regardless of how Caddy was installed):

headscale.seabee.me {
    handle /admin* {
        reverse_proxy 127.0.0.1:3000
    }
    handle {
        reverse_proxy 127.0.0.1:8080
    }
}

Note the target is 127.0.0.1:8080 / 127.0.0.1:3000 — host loopback, not a Docker container hostname like headscale:8080. That hostname-based addressing only resolves inside the headscale-net Docker bridge network, which your native Caddy process isn't part of.


5. headplane/config/config.yaml

Unchanged from the original guide, provided you generate a real cookie_secret and fill in api_key after first boot (step 6 below):

server:
  host: "0.0.0.0"
  port: 3000
  cookie_secret: "REPLACE_WITH_32_CHAR_RANDOM_STRING"
  base_url: "https://headscale.seabee.me"

headscale:
  url: "http://headscale:8080"
  config_path: "/etc/headscale/config.yaml"
  api_key: "REPLACE_AFTER_HEADSCALE_STARTS"

integration:
  docker:
    enabled: true
    container_name: "headscale"

headscale.url stays as http://headscale:8080 here — that's correct, because this is Headplane-to-Headscale traffic, which stays inside the Docker network (both containers are on headscale-net), unlike Caddy which is outside it.

openssl rand -hex 32

6. Bring it up

cd ~/Docker/headscale
docker compose up -d headscale
docker exec headscale headscale users create myuser
docker exec headscale headscale apikeys create --expiration 90d

Paste that API key into headplane/config/config.yaml → headscale.api_key, then:

docker compose up -d

Reload Caddy (command depends on your actual install — see section 4). Then visit https://headscale.seabee.me/admin.