Linux


General Info

General Info

Directory Structure

 

 

Directory Purpose
/ Root of the filesystem
/bin Essential command binaries
/sbin Essential system binaries
/etc Configuration files
/var Variable data (logs, cache, etc.)
/var/log System and application logs
/usr User applications and binaries
/home User home directories
/root Root user’s home directory
/boot Boot files and kernel
/opt Optional third-party software
/tmp Temporary files
/dev Device files (disks, terminals)
/proc Process and system info
/sys Kernel-related info
/lib, /lib64 Shared libraries
/media Auto-mounted removable media
/mnt Temporary mount points
/srv Data for services (web, FTP)

Web server management

Web server management

Apache setup & Virtual servers

Initial setup

Install packages sudo apt install apache2 mariadb-server libapache2-mod-php php-mysql -y

Open ports at  cd /etc/apache2/ports.conf and add the line Listen NEW_PORT_NUMBER

Creating virtual servers

Create a file named NAME.conf and add something like this

<VirtualHost *:80>
        ServerName pdb.conorbriggs.com.au
        DocumentRoot /mnt/drives/10tb/10tb/web_servers/pdb/

<Directory /mnt/drives/10tb/10tb/web_servers/pdb/>
        Options -Indexes -FollowSymLinks
        AllowOverride All
        Require all granted
</Directory>
ErrorLog /var/log/apache2/pdb-error.log
CustomLog /var/log/apache2/pdb-access.log combined

RewriteEngine on
</VirtualHost>

Enable the site & restart apache with sudo a2ensite site.conf && systemctl restart apache2


SSL Certificate management

Install certbot and run setup

sudo apt install certbot python3-certbot-apache
a2enmod ssl
sudo certbot --apache
List all certificates with certbot certificates
Delete a certificaite certbot delete --cert-name CERT_NAME_HERE

Managing the apache2 service

Start, stop & restart sudo service apache2 start/restart/stop

Enable a config sudo a2ensite SITE_CONF_FILE.conf
Disable a config sudo a2dissite SITE_CONF_FILE.conf
Web server management

SQL

After install

ensure secure access sudo mysql_secure_installation

 Logging in 

mysql -u USERNAME -p

add -h HOSTNAME IP when logging in remotely

Creating a user

CREATE USER 'NEW_USERNAME'@'localhost' IDENTIFIED BY 'NEW_PASSWORD';

Creating a database

CREATE DATABASE DATABASE_NAME

Allow user to modify Database

GRANT ALL PRIVILEGES ON DATABASE_NAME.* TO 'USERNAME'@'localhost';

Update permissions

FLUSH PRIVILEGES;

Open Database to the world

  1. Open file at /etc/mysql/maridadb.conf.d/50-server.cnf
  2. Edit line bind-address = 127.0.0.1 to -> bind-address = 0.0.0.0
  3.  Restart db service systemctl restart mariadb
  4. Allow port through firewall with ufw allow 3306

Automated backup script

#!/bin/bash

# === Config ===
BACKUP_DIR="/var/backups/mysql"
DATE=$(date +"%Y-%m-%d_%H-%M")
DB_USER="backupuser"
DB_PASS="yourpassword"

# Create backup dir if not exist
mkdir -p "$BACKUP_DIR"

# Dump all databases
mysqldump -u "$DB_USER" -p"$DB_PASS" --all-databases --single-transaction > "$BACKUP_DIR/mariadb-$DATE.sql"

# Optional: Compress the backup
gzip "$BACKUP_DIR/mariadb-$DATE.sql"

# Delete backups older than 7 days
find "$BACKUP_DIR" -name "*.sql.gz" -type f -mtime +7 -delete

 

 

 

 

 

 

 

 

Web server management

SSL & Certificate management

Install certbot and run setup

sudo apt install certbot python3-certbot-apache
a2enmod ssl
sudo certbot --apache

List all certificates

certbot certificates

Delete a certificaite

certbot delete --cert-name CERT_NAME_HERE

Web server management

PostgreSQL

Install

Install postgresql and postgis for coordinates data

sudo apt install postgresql postgis -y

 

Login

sudo -u postgres psql

 

Change password to default account

ALTER USER postgres WITH PASSWORD 'new password';

 

Restrict remote access to localhost only

Open the config file

nano /etc/postgresql/15/main/postgresql.conf

uncomment this line

listen_addresses = 'localhost'

if you need remote access

listen_addresses = '0.0.0.0'

and Edit /etc/postgresql/15/main/pg_hba.conf and add a line like:

host    all             all             192.168.1.0/24          scram-sha-256

 

Web server management

SQL Reference

Create a table that auto deletes on other table deletion

CREATE TABLE tradie_postcode_covered (
    tradie_id INT,
    postcode VARCHAR(10),
    PRIMARY KEY (tradie_id, postcode), -- primary key ensures no tradie can be in the same postcode twice
    FOREIGN KEY (tradie_id) REFERENCES tradies(id) ON DELETE CASCADE
);

Web server management

Mailcow Dockerized Setup Guide (Home Server + VPS Proxy + Tailscale)

This guide installs Mailcow on your home server (behind CGNAT), with your VPS handling public-facing TLS/proxying via Caddy (web UI/webmail) and HAProxy (raw mail ports), connected over Tailscale. Sourced from the current official Mailcow docs (docs.mailcow.email) as of mid-2026.


0. Architecture overview

Internet
   │
   ▼
 VPS (public IP)
   ├── Caddy   → HTTPS termination for mail.seabee.me (webmail/admin UI)
   │              reverse_proxy → home server Tailscale IP:8005 (HTTP; X-Forwarded-Proto https)
   ├── HAProxy → raw TCP forward for 25 / 465 / 587 / 993 / 4190
   │              → home server Tailscale IP (same ports), with PROXY protocol
   │                so the real client IP survives (Section 8)
   └── Postfix relay (send-only, Tailscale-facing)
                  ← mailcow relays ALL outbound here; egresses from the VPS
                    public IP so it matches your PTR + SPF (Section 9)
                        │
                        ▼ (Tailscale tunnel)
                Home server (behind CGNAT)
                   └── Mailcow (Docker): Postfix, Dovecot, SOGo, Rspamd, ClamAV, etc.

Two things this topology forces that a normal single-box mailcow doesn't:

Mailcow itself runs entirely on your home server. The VPS never stores mail — it just forwards TLS/TCP traffic through the Tailscale tunnel.

On certificates: Caddy on the VPS obtains a cert for the web UI (it has public 80/443, so HTTP-01 works there). But the raw mail ports (465/587/993) are TCP-passed-through, so their TLS terminates inside Dovecot/Postfix on the home server — those services need their own valid cert or clients hit "untrusted certificate". The home server is behind CGNAT and can't answer HTTP-01, so it can't run the normal ACME flow. This guide solves it by syncing the cert Caddy already holds down to the home server (Section 4a, "Option B") — cbcore pulls it over Tailscale, drops it into mailcow, and restarts the mail services. mailcow's own ACME client stays off (SKIP_LETS_ENCRYPT=y) so it doesn't fight the synced cert. A DNS-01 alternative is documented too, for anyone whose DNS provider has a usable API.


1. Prerequisites

Hardware (home server)

If your home server is resource-constrained, you can disable ClamAV and full-text search in mailcow.conf (SKIP_CLAMD=y, SKIP_FTS=y) to run comfortably on ~2 GB RAM.

OS support

Mailcow supports KVM/ESXi/Hyper-V VMs and bare metal. It explicitly does not support Synology/QNAP NAS devices, OpenVZ, or LXC containers — only real Docker hosts.

Required packages

sudo apt update
sudo apt install -y git openssl curl gawk coreutils grep jq

(jq is a fairly recent addition to Mailcow's requirements — make sure it's installed.)

Docker (latest engine, not distro package)

curl -sSL https://get.docker.com/ | CHANNEL=stable sh
sudo systemctl enable --now docker

Docker Compose plugin (v2.0+)

sudo apt install docker-compose-plugin

Confirm:

docker compose version   # must be >= 2.0

Debian 13 (trixie) note

If you upgraded from Debian 12→13, a package called exim may get pulled in and bind port 25 on the host, blocking Mailcow's Postfix container from using it. Remove it first:

sudo apt remove --purge exim4 exim4-base exim4-config -y

2. DNS records to configure

Set these at your domain registrar (Namecheap → Advanced DNS), pointing at your VPS's public IP (since that's what actually receives inbound connections):

# Name              Type       Value
mail                IN A       203.57.114.42
autodiscover        IN CNAME   mail.seabee.me.
autoconfig          IN CNAME   mail.seabee.me.
@                   IN MX 10   mail.seabee.me.
vps                 IN A       203.57.114.42

The vps A record matters here: the VPS relay identifies itself as vps.seabee.me when sending outbound (Section 9), and its PTR must match that name (below) — which only forward-confirms if vps.seabee.me also resolves to the VPS IP.

In Namecheap specifically

SPF, DKIM, DMARC

@                   IN TXT     "v=spf1 mx a -all"
_dmarc              IN TXT     "v=DMARC1; p=none; rua=mailto:postmaster@seabee.me"

Both go under Host Records as TXT records. DKIM is generated inside the Mailcow admin UI after install (Configuration → ARC/DKIM keys) — you'll copy that TXT record in afterward, also as a Host Record.

On the SPF mx a -all: this only holds true because outbound mail is relayed through the VPS (Section 9), so mail leaves from the VPS IP, which is exactly what mail.seabee.me's A record (and therefore mx) resolves to. If you ever let the home server send directly, it egresses from the CGNAT IP and this SPF fails hard — see Section 9.

Stage DMARC — do not launch at p=reject. Start at p=none as above. It changes nothing about delivery but makes receivers send you aggregate reports at the rua address. Watch those for a week or two and confirm your legitimate mail is passing DKIM and SPF with alignment. Only then tighten to p=quarantine, and later p=reject. Launching straight at reject means any alignment mistake silently bins your own mail at the recipient with no warning.

Reverse DNS (PTR)

Critical for deliverability: your VPS provider, not your DNS zone, controls this — PTR belongs to whoever owns the IP block. The PTR for 203.57.114.42 should resolve to vps.seabee.me, not mail.seabee.me.

Why vps, not mail: the box that actually connects to Gmail/etc. and sends your outbound mail is the VPS relay (Section 9), and it greets remote servers with HELO vps.seabee.me (it can't use mail.seabee.me — that name belongs to cbcore, and a shared name makes Postfix think it's talking to itself; see Section 9). Receivers run FCrDNS: they check that the connecting IP's PTR matches the HELO name and forward-confirms. So HELO (vps.seabee.me), PTR (vps.seabee.me), and the vps A record must all agree. A PTR of mail.seabee.me while the relay HELOs vps.seabee.me is a mismatch and a (small but real) spam signal. Note SPF is unaffected either way — it checks the sending IP against v=spf1 mx a -all, and the IP is authorised regardless of name.

On Binary Lane: mPanel → Network/IP settings → the "Reverse DNS" field next to your public IPv4 address. Set it to vps.seabee.me. Binary Lane uses a 12-hour TTL for PTR records, so allow up to half a day to propagate. Make sure the vps A record exists first.

Verify once propagated:

dig +short mail.seabee.me        # → your VPS IP, and ONLY that
dig +short -x 203.57.114.42       # → vps.seabee.me.   (matches the relay HELO)
dig +short vps.seabee.me          # → 203.57.114.42     (forward-confirms the PTR)

mail must resolve to exactly one IP — the VPS. If dig +short mail.seabee.me returns two addresses (e.g. the VPS and your home connection's public IP), delete the stray record. A common cause is a leftover dynamic-DNS A record or a wildcard * A record pointing home. With two A records, clients pick one at random, so ~half of inbound mail and client connections hit your home IP (behind CGNAT, nothing listening) and time out. Since your MX points at mail.seabee.me, this silently corrupts delivery. Fix it before any mail-flow testing.

Changing the PTR only affects reverse lookups on that IP — any other hostnames already pointing at the same IP (e.g. a vps.seabee.me you use for SSH) keep working exactly as before, since PTR and A records are independent.

Optional but recommended: autoconfig SRV records

Full zone-file form:

_autodiscover._tcp  IN SRV  0 1 443  mail.seabee.me.
_imaps._tcp         IN SRV  0 1 993  mail.seabee.me.
_submission._tcp    IN SRV  0 1 587  mail.seabee.me.
_submissions._tcp   IN SRV  0 1 465  mail.seabee.me.
_sieve._tcp         IN SRV  0 1 4190 mail.seabee.me.

In Namecheap, SRV records use separate fields rather than one string. Add each one under Host Records → Add New Record → SRV Record:

Service Protocol Priority Weight Port Target
_autodiscover _tcp 0 1 443 mail.seabee.me
_imaps _tcp 0 1 993 mail.seabee.me
_submission _tcp 0 1 587 mail.seabee.me
_submissions _tcp 0 1 465 mail.seabee.me
_sieve _tcp 0 1 4190 mail.seabee.me

These aren't required for mail to function — the autodiscover/autoconfig CNAMEs above already cover most clients — but they add broader compatibility (notably Outlook via _autodiscover). Fine to add later once core mail delivery is confirmed working.


3. Install Mailcow on the home server

umask 0022
mkdir -p /home/conor/Docker
cd /home/conor/Docker
git clone https://github.com/mailcow/mailcow-dockerized mailcow
cd mailcow
./generate_config.sh

Note: Mailcow's own docs default to /opt/mailcow-dockerized, but any path works — Mailcow doesn't hardcode /opt anywhere; it just uses whatever directory you run generate_config.sh and docker compose from. The only requirement is that the user running these commands has read/write access to the directory and to the Docker socket (i.e. is in the docker group, or you run the commands with sudo).

You'll be prompted for:

This creates mailcow.conf.


4. Edit mailcow.conf — key settings for your proxy setup

nano mailcow.conf

Change/confirm these values:

MAILCOW_HOSTNAME=mail.seabee.me

# Turn mailcow's own ACME client OFF. We supply the cert externally by syncing
# Caddy's cert into mailcow (Section 4a, Option B), so mailcow must NOT try to
# obtain/renew its own — otherwise it overwrites the synced files.
# (If you instead choose the DNS-01 alternative in 4a, set these the other way:
#  SKIP_LETS_ENCRYPT=n / ACME_DNS_CHALLENGE=y.)
SKIP_LETS_ENCRYPT=y
ACME_DNS_CHALLENGE=n

# Skip the public-IP-matches-DNS check, since this host is behind CGNAT
SKIP_IP_CHECK=y

# Disable IPv6 — nothing in the VPS→Tailscale→home path uses it, and half-enabling
# it (detected but not configured in Docker's daemon.json) just invites confusion
ENABLE_IPV6=false

# Web UI / webmail (nginx). Caddy reverse-proxies to the HTTP port (8005) —
# see Section 8. Two settings make that work behind Caddy without a redirect loop:
#   HTTP_REDIRECT=n           -> mailcow must NOT do its own HTTP->HTTPS redirect.
#                                Caddy already guarantees HTTPS to the outside world;
#                                with =y, mailcow 301-redirects every proxied request
#                                and you get an infinite loop (ERR_TOO_MANY_REDIRECTS).
#   ADDITIONAL_SERVER_NAMES   -> the hostname your reverse proxy uses, so mailcow's
#                                nginx serves the right vhost instead of falling back
#                                to its internal address (which breaks SOGo webmail).
HTTP_BIND=100.64.0.3
HTTP_PORT=8005
HTTPS_BIND=100.64.0.3
HTTPS_PORT=8443
HTTP_REDIRECT=n
ADDITIONAL_SERVER_NAMES=mail.seabee.me

# Mail services (Postfix/Dovecot). Each is bound with a single IP:PORT variable.
# This is the native mailcow mechanism for scoping mail ports to one interface —
# do NOT use a docker-compose.override.yml for this (see Section 5 for why).
SMTP_PORT=100.64.0.3:25
SMTPS_PORT=100.64.0.3:465
SUBMISSION_PORT=100.64.0.3:587
IMAP_PORT=100.64.0.3:143
IMAPS_PORT=100.64.0.3:993
SIEVE_PORT=100.64.0.3:4190

# Leave the local-only admin/DB ports on 127.0.0.1 — do not scope these to Tailscale
# DOVEADM_PORT=127.0.0.1:19991
# SQL_PORT=127.0.0.1:13306
# REDIS_PORT=127.0.0.1:7654

# Adjust if these clash with anything else on your Docker host
IPV4_NETWORK=172.22.1

Replace 100.64.0.3 with your home server's actual Tailscale IP (tailscale ip -4).

Why HTTP_PORT=8005 rather than the Mailcow default of 8080: purely to avoid clashing with anything else already using 8080 on this host — pick whatever's free. Caddy reverse-proxies to this port (8005) over plain HTTP inside the Tailscale tunnel; HTTPS_PORT=8443 ends up unused externally. This is the reverse-proxy layout mailcow documents (proxy to the HTTP port, pass X-Forwarded-Proto https), and it's what makes SOGo webmail generate correct mail.seabee.me URLs — see Section 8.


4a. Certificates for the mail ports

Caddy on the VPS handles TLS for the web UI. But HAProxy passes the mail ports (465/587/993) through as raw TCP, so their TLS is terminated by Dovecot/Postfix on the home server — and with a self-signed cert, every mail client shows a warning and remote MTAs distrust your STARTTLS. mailcow's cert is used for mail transport, not just HTTPS, so the home server genuinely needs a trusted one.

The home server can't answer an HTTP-01 challenge (no public 80/443 — the CGNAT problem), so it can't run the normal ACME flow itself. Two ways around that:

Option B — pull Caddy's cert onto cbcore

The design is pull, not push: cbcore reaches out to the VPS to fetch one cert, rather than the internet-facing VPS holding a key that can write files and restart services on your home server. The VPS key is locked to a forced command that can only emit that one cert — a stolen key can't get a shell.

Step 1 — on the VPS, create the emitter script (streams just the cert + key as a tar on stdout). The 'EOF' is single-quoted so the shell writes the variables literally instead of expanding them now:

sudo tee /usr/local/bin/emit-mail-cert >/dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
DOMAIN="mail.seabee.me"
CADDY_DATA="/var/lib/caddy/.local/share/caddy/certificates"
crt="$(find "$CADDY_DATA" -type f -name "${DOMAIN}.crt" | head -n1)"
key="$(find "$CADDY_DATA" -type f -name "${DOMAIN}.key" | head -n1)"
[[ -r "$crt" && -r "$key" ]] || { echo "cert not found" >&2; exit 1; }
tar -C "$(dirname "$crt")" -cf - "$(basename "$crt")" "$(basename "$key")"
EOF
sudo chmod 0755 /usr/local/bin/emit-mail-cert

Step 2 — on cbcore, create the pull key and print the public half:

ssh-keygen -t ed25519 -f /home/conor/.ssh/vps_cert_pull -N ''
cat /home/conor/.ssh/vps_cert_pull.pub

Step 3 — on the VPS, authorise that key but lock it to the emitter. Caddy's key is 0600 and root-owned, so the emitter must run as root; put the line in root's authorized_keys. The command=/no-* restrictions mean this key can only emit the cert:

# on the VPS — paste the cbcore public key where shown
sudo mkdir -p /root/.ssh && sudo chmod 700 /root/.ssh
echo 'command="/usr/local/bin/emit-mail-cert",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAA...PASTE_CBCORE_cert_pull.pub...' \
  | sudo tee -a /root/.ssh/authorized_keys >/dev/null
sudo chmod 600 /root/.ssh/authorized_keys

Verify the lock from cbcore — connect as root (that's whose authorized_keys holds the line), which should list exactly two files and give no shell:

ssh -i /home/conor/.ssh/vps_cert_pull root@100.64.0.2 | tar -tf -
# → mail.seabee.me.crt  and  mail.seabee.me.key  only
# A "PTY allocation request failed on channel 0" line is EXPECTED and good —
# it's the no-pty restriction refusing an interactive shell.

This requires the VPS to allow key-based root login. Check with sudo sshd -T | grep -i permitrootlogin; if it's no, set PermitRootLogin prohibit-password (allows key, blocks password root login) and sudo sshd -t && sudo systemctl reload ssh. Don't use plain yes — that permits password root login too, which is a brute-force magnet on a public IP.

Step 4 — on cbcore, install the sync script. It pulls, checks the cert fingerprint against what's already deployed, and only installs + restarts when it actually changed (so it's safe to run daily and is a no-op the ~59 days out of 60 that nothing changed):

sudo tee /usr/local/bin/pull-mailcow-cert.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
# Pulls the Caddy-issued cert for mail.seabee.me FROM the VPS, installs it into
# mailcow, and restarts mail services ONLY when the cert changed. Idempotent.
set -euo pipefail

VPS_USER="root"                                  # whose authorized_keys holds the locked key
VPS_HOST="100.64.0.2"                            # VPS, Tailscale IP
SSH_KEY="/home/conor/.ssh/vps_cert_pull"
MAILCOW="/home/conor/Docker/mailcow"
SSL="${MAILCOW}/data/assets/ssl"
DOMAIN="mail.seabee.me"
SSH_OPTS=(-i "$SSH_KEY" -o BatchMode=yes -o ConnectTimeout=15 \
          -o StrictHostKeyChecking=accept-new)

log()  { printf '%s  %s\n' "$(date -Is)" "$*"; }
fail() { log "ERROR: $*"; exit 1; }

tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT

ssh "${SSH_OPTS[@]}" "${VPS_USER}@${VPS_HOST}" | tar -C "$tmp" -xf - \
    || fail "pull from VPS failed (ssh/forced-command/tar)"

crt="$(find "$tmp" -type f -name "${DOMAIN}.crt" | head -n1 || true)"
key="$(find "$tmp" -type f -name "${DOMAIN}.key" | head -n1 || true)"
[[ -r "${crt:-}" && -r "${key:-}" ]] || fail "expected cert/key not in pulled tar"

openssl x509 -in "$crt" -noout -checkend 0 >/dev/null \
    || fail "pulled cert has already expired — refusing to deploy"

new_fp="$(openssl x509 -in "$crt" -noout -fingerprint -sha256 | cut -d= -f2)"
cur_fp="$(openssl x509 -in "${SSL}/cert.pem" -noout -fingerprint -sha256 2>/dev/null \
          | cut -d= -f2 || true)"
if [[ -n "$cur_fp" && "$new_fp" == "$cur_fp" ]]; then
    log "mailcow already current (${new_fp}); nothing to do."
    exit 0
fi
log "new cert (${new_fp}, was ${cur_fp:-none}) — installing and restarting."

install -m 0644 "$crt" "${SSL}/cert.pem"
install -m 0600 "$key" "${SSL}/key.pem"
cd "$MAILCOW"
docker compose restart postfix-mailcow dovecot-mailcow nginx-mailcow \
    || fail "cert installed but container restart failed — check docker"
log "cert deployed and mail services restarted."
EOF
sudo chmod 0755 /usr/local/bin/pull-mailcow-cert.sh

Step 5 — bootstrap once (replaces the snake-oil cert immediately) and confirm cbcore's conor can drive Docker:

docker compose version                        # must work without sudo (conor in docker group)
/usr/local/bin/pull-mailcow-cert.sh

Step 6 — schedule it daily on cbcore via a systemd timer. Daily rather than monthly on purpose: Caddy renews ~30 days before expiry and that won't align to a monthly cron, so a fixed monthly copy can leave cbcore's cert lapsing before the next run. Daily + the fingerprint guard eliminates that with no extra load:

sudo tee /etc/systemd/system/mailcow-cert-pull.service >/dev/null <<'EOF'
[Unit]
Description=Pull Caddy cert from VPS into mailcow
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
User=conor
ExecStart=/usr/local/bin/pull-mailcow-cert.sh
EOF

sudo tee /etc/systemd/system/mailcow-cert-pull.timer >/dev/null <<'EOF'
[Unit]
Description=Daily mailcow cert pull

[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=1h

[Install]
WantedBy=timers.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now mailcow-cert-pull.timer

Step 7 — expiry watchdog (the one thing people skip and regret). The sync breaks silently if Caddy's storage path changes on an update or the key rotates — you'd only find out when the mail cert lapses ~60 days later. This checks daily and, on a warning, appends a timestamped line to /home/conor/warning.txt:

sudo tee /usr/local/bin/mailcow-cert-expiry-check.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
# Warns if the mailcow mail cert is within 10 days of expiry (or missing).
# On warning, appends a timestamped line to /home/conor/warning.txt.
set -euo pipefail

CERT="/home/conor/Docker/mailcow/data/assets/ssl/cert.pem"
WARN_FILE="/home/conor/warning.txt"
DAYS=10

if ! openssl x509 -in "$CERT" -noout -checkend $((DAYS*86400)) >/dev/null 2>&1; then
    enddate="$(openssl x509 -in "$CERT" -noout -enddate 2>/dev/null | cut -d= -f2 || true)"
    printf '%s  WARNING: mailcow mail cert expires within %d days or is missing (%s) notAfter=%s\n' \
        "$(date -Is)" "$DAYS" "$CERT" "${enddate:-unknown}" >> "$WARN_FILE"
    exit 1
fi
exit 0
EOF
sudo chmod 0755 /usr/local/bin/mailcow-cert-expiry-check.sh

# schedule daily from CONOR's crontab (not root) so /home/conor/warning.txt is conor-owned
( crontab -l 2>/dev/null; \
  echo '30 6 * * * /usr/local/bin/mailcow-cert-expiry-check.sh' ) | crontab -

Note the cron is added with plain crontab (as conor), not sudo crontab — that way the warning file it creates is owned by you, not root. On a healthy day the script writes nothing and exits 0; it only touches warning.txt when the cert is genuinely close to expiry or missing. Force a test line without waiting for a real expiry by asking whether the cert is valid 89 days out (it won't be):

CERT=/home/conor/Docker/mailcow/data/assets/ssl/cert.pem
openssl x509 -in "$CERT" -noout -checkend $((89*86400)) \
  || echo "$(date -Is)  TEST warning line" >> /home/conor/warning.txt
cat /home/conor/warning.txt

Alerting: a line in a file only helps if something reads it. Since you run Telegram (the "James" agent), point a notifier at warning.txt — or swap the printf for a curl to the Telegram Bot API — so the alert reaches you rather than sitting in a file you never open.

Verify the whole thing in Section 12 (the openssl s_client … -issuer check should now show Let's Encrypt, not the mailcow snake-oil CA).

Option A — DNS-01 (alternative, only if your DNS API is usable)

If you can use DNS-01, it's cleaner: mailcow gets and renews its own cert, no cross-host sync. Set SKIP_LETS_ENCRYPT=n and ACME_DNS_CHALLENGE=y in Section 4, then pass your DNS provider's API token to the acme-mailcow container. mailcow runs acme.sh, so ACME_DNS_PROVIDER takes an acme.sh dnsapi code. Cloudflare example:

cd /home/conor/Docker/mailcow
sudo tee docker-compose.override.yml >/dev/null <<'EOF'
services:
  acme-mailcow:
    environment:
      - CF_Token=<cloudflare-scoped-api-token>
      - CF_Zone_ID=<zone-id-for-seabee.me>
EOF
docker compose up -d acme-mailcow
docker compose logs -f acme-mailcow          # watch for a successful issue

Namecheap can't do this from CGNAT. Namecheap's API is granted only above an account threshold and requires whitelisting the calling IPv4 — your acme requests would leave via the ISP's shared, rotating CGNAT address, which you can't reliably whitelist. Moving DNS hosting (not the registrar) to Cloudflare removes both problems (free dns_cf, token auth, no whitelist); recreate the Section 2 records there and repoint nameservers. If you'd rather not touch DNS, stay on Option B above.


5. Why the mail ports live in mailcow.conf, not an override file

By default, Mailcow binds mail ports to all interfaces (0.0.0.0). Since your home server sits behind CGNAT, nothing on the raw internet can reach these ports regardless — but leaving them on 0.0.0.0 still means every device on your home LAN and every peer on your tailnet can connect to them directly, bypassing HAProxy entirely. Scoping to the Tailscale IP limits that down to just the VPS.

Mailcow binds every non-http service through a single IP:PORT variable in mailcow.conf. You already set these in Section 4 — the format is documented at docs.mailcow.email → IP bindings (SMTP_PORT=1.2.3.4:25 binds SMTP to 1.2.3.4:25):

SMTP_PORT=100.64.0.3:25
SMTPS_PORT=100.64.0.3:465
SUBMISSION_PORT=100.64.0.3:587
IMAP_PORT=100.64.0.3:143
IMAPS_PORT=100.64.0.3:993
SIEVE_PORT=100.64.0.3:4190

Do not use a docker-compose.override.yml for this (an earlier draft of this guide did — it's wrong). The base docker-compose.yml already renders each of these ports from the variable above (e.g. ${SMTP_PORT:-25}:25). An override file that also defines a ports: list does not replace the base list — Compose merges the two, and for the multi-value ports option it concatenates/merges by uniqueness key rather than overriding. Depending on your Compose version you either end up with both 0.0.0.0:25 and 100.64.0.3:25 bound (which fails at launch with port is already allocated, since 0.0.0.0:25 already covers the Tailscale IP) or a silent, version-dependent merge. Setting the mailcow.conf variable is the officially documented method and has no merge ambiguity — the base compose file is left untouched, so ./update.sh never fights your config.

A few points worth knowing:

Apply the change with a full down/up, since you're changing the rendered port mappings (a bare docker compose up -d won't always re-map ports cleanly):

docker compose down
docker compose up -d
docker compose ps

Verify the bindings landed on the right interface:

sudo ss -tlpn | grep -E ':(25|143|465|587|993|4190|8005|8443)\b'

You should see 100.64.0.3 (not 0.0.0.0) next to each mail/web port. 19991, 13306 and 7654 should still show 127.0.0.1.


6. Home server firewall — and why ufw isn't the right tool here

The obvious instinct is a ufw rule allowing only the VPS:

# This does NOT actually filter the mailcow ports — see below
sudo ufw allow from <VPS_TAILSCALE_IP> to any port 25,465,587,993,4190,8005,8443 proto tcp

This gives false confidence. Docker publishes container ports by inserting its own rules into the DOCKER iptables chain, which is evaluated before ufw's INPUT chain — so ufw allow/deny rules do not govern published container ports at all. A ufw deny here wouldn't block anything.

What's actually limiting exposure is the work you already did in Section 4/5: binding every mail port to the Tailscale IP (100.64.0.3) means they only exist on the tailnet interface, unreachable from the LAN or the internet. The correct place to then restrict which tailnet peers may reach them is a Tailscale ACL, not a host firewall. In your tailnet policy file, scope the mail ports to the VPS only — for example:

// Tailscale ACL — only the VPS may reach the mail/UI ports on the home server
{
  "acls": [
    {
      "action": "accept",
      "src":    ["<vps-node>"],
      "dst":    ["<home-node>:25,465,587,993,4190,8005,8443"]
    }
  ]
}

(Replace <vps-node>/<home-node> with the device names or tags from your tailnet.) This is enforced by Tailscale regardless of Docker's iptables behaviour.

If you do want host-level enforcement as defence in depth, add rules to the DOCKER-USER chain (which Docker leaves for exactly this) rather than ufw:

# Debian 13, nftables-backed iptables — verify against `iptables -L DOCKER-USER`
sudo iptables -I DOCKER-USER -i tailscale0 -s <VPS_TAILSCALE_IP> -j RETURN
sudo iptables -I DOCKER-USER -i tailscale0 -j DROP

Persist these (e.g. netfilter-persistent save) — DOCKER-USER rules are not saved across reboots by default.


7. Start Mailcow

docker compose pull
docker compose up -d
docker compose ps

You should see 15+ containers reporting Up.


8. VPS side — Caddy and HAProxy (inbound)

Caddy (web UI / webmail / autodiscover — HTTPS termination)

Add a site block to your existing Caddyfile. Proxy to mailcow's HTTP port (8005) — this is mailcow's documented reverse-proxy layout, and it's what makes SOGo webmail work. Getting this right depends on the three things set together, here and in Section 4:

mail.seabee.me {
    reverse_proxy 100.64.0.3:8005 {
        header_up Host {host}
        header_up X-Forwarded-Proto https
    }
}

autodiscover.seabee.me, autoconfig.seabee.me {
    reverse_proxy 100.64.0.3:8005 {
        header_up Host {host}
        header_up X-Forwarded-Proto https
    }
}

Plain HTTP to 8005 (no https://, no tls_insecure_skip_verify) — the hop rides inside the encrypted Tailscale tunnel, so there's nothing to gain from TLS on that leg, and it sidesteps the snake-oil-cert-on-8443 problem entirely. The browser only ever talks to Caddy, which serves its own valid Let's Encrypt cert for mail.seabee.me, and Caddy handles ACME issuance/renewal automatically.

Do not proxy to the HTTPS port (8443). It seems tempting (it dodges the redirect loop without HTTP_REDIRECT=n), but mailcow's nginx on 8443 emits its own internal-address redirects that make SOGo build 100.64.0.3:8443 resource URLs — fine on your tailnet (the IP resolves), but they hang forever for real external users. The 8005 + HTTP_REDIRECT=n combination above is the correct one.

Verify no loop after reloading, from a machine (or curl) — curl -skI https://mail.seabee.me/ should return HTTP/2 200, not 301 → https://mail.seabee.me/. And test webmail login from off your tailnet (phone on mobile data), since a tailnet machine can reach 100.64.0.3:8443 directly and will mask a lingering internal-URL problem.

HAProxy (raw mail ports — full /etc/haproxy/haproxy.cfg)

This writes the whole file, so it includes global and defaults (a HAProxy config can't run without them) plus all six mail frontends/backends. Design points baked in:

  1. Binds scoped to the VPS public IP, not *. bind *:25 occupies port 25 on every interface including Tailscale — which collides with the outbound relay Postfix in Section 9 (it needs 100.64.0.2:25). Scoping to the public IP keeps inbound and outbound cleanly separated.
  2. Plain passthrough to start — no send-proxy yet. check send-proxy makes HAProxy speak PROXY protocol on the health checks too, so until the home side is configured to accept it (Section 8 "Home side"), the backend rejects the header, the check fails, the server shows DOWN, and no mail flows. So bring it up plain, confirm mail works, then switch both ends together (command at the end of this section).
  3. mode tcp + long timeouts in defaults. This HAProxy only forwards mail, so defaults is TCP with 1-hour client/server timeouts — SMTP is fine either way, but IMAP IDLE holds a connection open ~29 minutes and the stock 50-second timeout would cut it. (The HTTP errorfiles from the stock Debian config are dropped; they only apply in mode http.)

Back up the current file first, then write it. Replace 203.57.114.42 with your VPS public IP if different (ip -4 addr to confirm it's actually on the interface, or the bind fails):

sudo cp -a /etc/haproxy/haproxy.cfg /etc/haproxy/haproxy.cfg.bak.$(date +%F)

sudo tee /etc/haproxy/haproxy.cfg >/dev/null <<'EOF'
global
    log /dev/log    local0
    log /dev/log    local1 notice
    chroot /var/lib/haproxy
    stats socket /run/haproxy/admin.sock mode 660 level admin
    stats timeout 30s
    user haproxy
    group haproxy
    daemon
    ca-base /etc/ssl/certs
    crt-base /etc/ssl/private
    ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
    ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
    ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets

defaults
    log     global
    mode    tcp
    option  tcplog
    option  dontlognull
    timeout connect 10s
    timeout client  1h
    timeout server  1h

# ============ Inbound mail -> cbcore (100.64.0.3) over Tailscale ============
# Binds scoped to the VPS public IP so mail ports stay free on the Tailscale
# interface for the outbound relay (Section 9). Plain passthrough for now;
# add " send-proxy-v2" to the server lines once the home side is ready.

frontend smtp_in
    bind 203.57.114.42:25
    default_backend smtp_home
backend smtp_home
    server home 100.64.0.3:25 check

frontend submission_in
    bind 203.57.114.42:587
    default_backend submission_home
backend submission_home
    server home 100.64.0.3:587 check

frontend smtps_in
    bind 203.57.114.42:465
    default_backend smtps_home
backend smtps_home
    server home 100.64.0.3:465 check

frontend imaps_in
    bind 203.57.114.42:993
    default_backend imaps_home
backend imaps_home
    server home 100.64.0.3:993 check

frontend sieve_in
    bind 203.57.114.42:4190
    default_backend sieve_home
backend sieve_home
    server home 100.64.0.3:4190 check
EOF

sudo haproxy -c -f /etc/haproxy/haproxy.cfg     # validate BEFORE reloading
sudo systemctl reload haproxy

Confirm every backend is UP (this is where a stray send-proxy would show DOWN):

echo 'show servers state' | sudo socat stdio /run/haproxy/admin.sock | awk '{print $4, $5, $6}'
# each home server should be state 2 (UP); or just: sudo systemctl status haproxy

Then, and only after the home side accepts PROXY protocol (next subsection), enable it — this appends send-proxy-v2 to every backend in one shot (run once):

sudo sed -i 's/\(server home [0-9.]*:[0-9]*\) check$/\1 check send-proxy-v2/' /etc/haproxy/haproxy.cfg
sudo haproxy -c -f /etc/haproxy/haproxy.cfg && sudo systemctl reload haproxy

Why bother with send-proxy-v2 at all: without it the home-side Postfix/Dovecot see every connection as coming from the VPS, so mailcow's fail2ban bans the VPS on the first brute-force burst (killing all inbound mail) and Rspamd loses its RBL/reputation signal.

Home side — teach Postfix/Dovecot to accept PROXY protocol

mailcow persists these overrides in data/conf/, so they survive ./update.sh. Set the trusted source to the VPS Tailscale IP (100.64.0.2 in this guide) — the only thing that will ever send a PROXY header. Note the data/ tree is root-owned (mailcow's containers create it as root), so these use sudo tee — a plain tee/>> gives Permission denied, and prefixing the whole pipe with sudo wouldn't help because that only elevates the left side of the pipe, not the tee doing the write. Run from your mailcow dir on cbcore:

cd /home/conor/Docker/mailcow

# Postfix: postscreen handles port 25 (append only if not already present)
grep -q postscreen_upstream_proxy_protocol data/conf/postfix/extra.cf 2>/dev/null || \
  echo 'postscreen_upstream_proxy_protocol = haproxy' | sudo tee -a data/conf/postfix/extra.cf

# Dovecot: trust the VPS for PROXY headers on imaps/sieve
sudo tee -a data/conf/dovecot/extra.conf >/dev/null <<'EOF'
haproxy_trusted_networks = 100.64.0.2
haproxy_timeout = 30s
EOF

Confirm both writes actually landed (each should echo the line back):

grep postscreen_upstream_proxy_protocol data/conf/postfix/extra.cf
tail -n2 data/conf/dovecot/extra.conf

The submission/smtps ports (587/465) and the Dovecot listeners also each need a per-service flag — -o smtpd_upstream_proxy_protocol=haproxy on those Postfix master.cf services, and haproxy = yes on the Dovecot imaps/sieve inet_listener blocks.

Verify before editing master.cf / Dovecot listeners: the exact form of those per-service edits differs between mailcow releases, and master.cf/Dovecot listener blocks aren't as cleanly overridable as the extra.* files — check the current method against docs.mailcow.email and your running data/conf/postfix/master.cf rather than pasting blind. Then restart: docker compose restart postfix-mailcow dovecot-mailcow.

Order of operations — do the whole home side, THEN flip HAProxy. This extra.cf line only covers port 25 (postscreen). If you enable send-proxy-v2 on HAProxy while the submission/smtps ports and Dovecot listeners still don't expect PROXY protocol, those ports break. So finish all the home-side edits above (including the per-service master.cf/listener flags), restart the containers, and only then run the send-proxy-v2 sed from the previous subsection — both ends in one change. Until then, leave HAProxy on plain passthrough (the state you verified as all-backends-UP).

VPS firewall

sudo ufw allow 25,465,587,993,4190/tcp

(80/443 should already be open for Caddy. Note the ufw caveat from Section 6 applies only to the home server where Docker owns the chains — on the VPS, HAProxy/Postfix are host processes, so ufw governs them normally.)


9. Outbound mail — relay through the VPS

Why this is mandatory here. The home server is behind CGNAT, so if mailcow sends directly, mail leaves via your ISP's shared IP: port 25 outbound is usually blocked, the PTR isn't yours, and it fails SPF (your SPF authorises the VPS IP via mx, not the CGNAT IP). Routing outbound through the VPS makes the sending IP the VPS's public IP — which has the matching PTR (Section 2) and is covered by SPF. DKIM is unaffected: mailcow signs on the home server before handing the message off, so the relay just forwards an already-signed message.

9a. Send-only Postfix relay on the VPS

Run a minimal Postfix on the VPS that accepts mail only from the home server (over Tailscale) and sends it to the internet. It listens on the Tailscale IP so it doesn't collide with HAProxy (which now owns the public IP, Section 8).

Environment: the config below is for Postfix 3.x on Debian 12/13 (matching your home server; adjust if your VPS runs something else). Verify directives against postconf and the Debian postfix man pages before applying — Postfix defaults do shift between majors.

Install:

sudo apt update && sudo apt install -y postfix
# choose "Internet Site" / system mail name mail.seabee.me when prompted

Set the relay-relevant parameters with postconf -e (idempotent and scriptable — it edits main.cf in place, so you can re-run it safely). Replace <VPS_TAILSCALE_IP> with the VPS's Tailscale IP (100.64.0.2 here); 100.64.0.3 is cbcore, the only permitted sender.

The relay's hostname MUST differ from cbcore's. cbcore is mail.seabee.me. If the VPS relay also calls itself mail.seabee.me, then when cbcore hands it a message and the relay greets cbcore with HELO mail.seabee.me, cbcore's Postfix sees its own hostname coming back and aborts with mail for [100.64.0.2]:25 loops back to myself — mail never leaves. So the relay uses vps.seabee.me for both myhostname and smtp_helo_name. (This is also why the PTR is vps.seabee.me in Section 2 — HELO and PTR must match.)

sudo postconf -e \
  'myhostname = vps.seabee.me' \
  'smtp_helo_name = vps.seabee.me' \
  'mydestination =' \
  'relay_domains =' \
  'relayhost =' \
  'inet_interfaces = 100.64.0.2, 127.0.0.1' \
  'inet_protocols = ipv4' \
  'mynetworks = 127.0.0.0/8, 100.64.0.3/32' \
  'smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination' \
  'smtpd_recipient_restrictions = permit_mynetworks, reject_unauth_destination' \
  'smtpd_helo_required = yes' \
  'disable_vrfy_command = yes' \
  'smtp_tls_security_level = may' \
  'smtp_tls_loglevel = 1'

What these do: myhostname/smtp_helo_name = vps.seabee.me gives the relay a distinct identity (see the loop note above). mydestination=/relay_domains= empty means it's a pure relay — accepts local mail for nothing and is a backup MX for nothing. relayhost= empty means the relay delivers direct to the internet via MX lookup (it's the end of the chain — cbcore relays to it, it relays to the world; if it had a relayhost pointing back, you'd loop). inet_interfaces binds it to the Tailscale IP only (not the public IP — that's HAProxy's, Section 8). mynetworks trusts only cbcore, and reject_unauth_destination in both restriction lists is what stops it being an open relay — only cbcore may relay to arbitrary destinations. Apply:

sudo postfix check && sudo systemctl restart postfix
# postconf lives in /usr/sbin — to READ values as non-root, use: sudo postconf myhostname relayhost

Confirm it's listening on the Tailscale IP only, and is not an open relay:

sudo ss -tlpn | grep ':25'          # expect <VPS_TAILSCALE_IP>:25, not 0.0.0.0:25
# open-relay test from a THIRD host on your tailnet (not the home server):
#   swaks --to test@gmail.com --server <VPS_TAILSCALE_IP> --from x@evil.example
# → must be REJECTED with "Relay access denied"

Also confirm your VPS provider actually permits outbound port 25 (some block it by default and unblock on request) — otherwise delivery silently fails regardless of config.

9b. Point mailcow at the relay

This is two steps in two different screens — creating the transport is not the same as using it.

Create the transport: mailcow admin UI → Configuration → Routing → Sender-Dependent Transports → add:

There's a Test button on the new entry — useful, but be aware it only proves the transport is reachable; it does not prove your domain routes through it. That's the next step.

Attach it to the domain (the step that actually routes mail): Mail Setup → Domains → edit seabee.me → set the Sender-Dependent Transport dropdown to your [100.64.0.2]:25 entry → save. Until you do this, mailcow sends outbound directly from cbcore's CGNAT IP, which fails SPF at the recipient (mail-tester will report SPF fail ... ip=<your home IP>).

Then send from a real @seabee.me mailbox to mail-tester.com and confirm SPF, DKIM, DMARC all pass and the sending IP shown is the VPS (203.57.114.42), not your home IP.

Troubleshooting loops back to myself: if the bounce says mail for [100.64.0.2]:25 loops back to myself, the relay is sharing cbcore's hostname — check sudo postconf myhostname smtp_helo_name on the VPS reads vps.seabee.me, not mail.seabee.me (see the loop note in 9a). Watch the actual handoff on cbcore with docker compose logs -f postfix-mailcow while sending: a healthy send shows relay=100.64.0.2[100.64.0.2]:25 ... status=sent; the loop shows greeted me with my own hostname.

Hardening option: if you'd rather not trust by IP (e.g. you expect the tailnet to grow), run the VPS relay on submission (587) with SASL auth instead of mynetworks, and put credentials in the mailcow relayhost fields. IP-trust over a Tailscale-ACL-restricted link is defensible for a single known peer; SASL is the belt-and-suspenders version.


10. First login and initial setup (order matters)

https://mail.seabee.me/admin

Default credentials: admin / moohoo — change this immediately under Configuration → Access → Admin details.

Then set things up in this order — several later steps silently no-op if the domain doesn't exist yet:

  1. Add the domain first. Configuration → Mail Setup → Domains → Add seabee.me. Leave all the Relay checkboxes unchecked — those are for relaying inbound mail to another server; your mailboxes live here. Choose "add domain and restart SOGo" so webmail picks it up.
  2. Add a mailbox. Mailboxes → add you@seabee.me with a password. This is the account you send/receive as.
  3. Now generate DKIM (Configuration → ARC/DKIM keys): pick seabee.me, selector dkim, 2048 bits. Do not do this before step 1 — DKIM generation returns a success (HTTP 200) but stores nothing if the domain doesn't exist, and the UI list stays mysteriously empty.
  4. Attach the outbound relay (Section 9b): Mail Setup → Domains → edit seabee.me → set the Sender-Dependent Transport to your [100.64.0.2]:25 relay.

Where DKIM keys actually live: current mailcow stores them in Redis, not on disk — there is no data/dkim/keys/ directory (its absence is normal). To verify a key exists from the CLI: source mailcow.conf && docker compose exec redis-mailcow redis-cli -a "$REDISPASS" HKEYS DKIM_PUB_KEYS (should list seabee.me), and ... HGET DKIM_PUB_KEYS seabee.me prints the v=DKIM1;... TXT record to publish.


11. Post-install checklist


12. Testing

# From an external machine, confirm each port reaches Mailcow
openssl s_client -starttls smtp -connect mail.seabee.me:587
openssl s_client -connect mail.seabee.me:993

Verify the presented certificate is real, not self-signed (catches the Section 4a failure mode — a snakeoil cert on the mail ports). The issuer should be Let's Encrypt and the dates current:

openssl s_client -connect mail.seabee.me:993 2>/dev/null </dev/null \
  | openssl x509 -noout -issuer -subject -dates
# issuer= ... Let's Encrypt ...   subject=CN=mail.seabee.me   notAfter in the future
openssl s_client -starttls smtp -connect mail.seabee.me:587 2>/dev/null </dev/null \
  | openssl x509 -noout -issuer

If the issuer shows the mailcow self-signed CA, the cert sync hasn't landed — on cbcore run /usr/local/bin/pull-mailcow-cert.sh by hand and read its output (Section 4a). On the DNS-01 alternative, check docker compose logs acme-mailcow instead.

DNS/deliverability checks:


Quick reference: port map

Service Port VPS component Forwards to
Webmail/Admin UI 443 Caddy (HTTPS) home:8005 (HTTP; HTTP_REDIRECT=n)
SMTP 25 HAProxy (TCP) home:25
Submission 587 HAProxy (TCP) home:587
SMTPS 465 HAProxy (TCP) home:465
IMAPS 993 HAProxy (TCP) home:993
ManageSieve 4190 HAProxy (TCP) home:4190

Appendix: interface bindings at a glance

All host-interface scoping is done in mailcow.conf — no override file at all with the default cert setup (Option B, Section 4a). A docker-compose.override.yml only appears if you choose the DNS-01 alternative, and even then only to pass the ACME provider credentials to acme-mailcow — never for ports.

Variable Value Notes
HTTP_BIND / HTTP_PORT 100.64.0.3 / 8005 Separate bind+port pair (http quirk)
HTTPS_BIND / HTTPS_PORT 100.64.0.3 / 8443 Separate bind+port pair
SMTP_PORT 100.64.0.3:25 Single IP:PORT form
SMTPS_PORT 100.64.0.3:465
SUBMISSION_PORT 100.64.0.3:587
IMAP_PORT 100.64.0.3:143
IMAPS_PORT 100.64.0.3:993
SIEVE_PORT 100.64.0.3:4190
DOVEADM_PORT 127.0.0.1:19991 Leave local-only
SQL_PORT 127.0.0.1:13306 Leave local-only
REDIS_PORT 127.0.0.1:7654 Leave local-only

Software & packages

Software & packages

Image manipulation

Compression

Install sudo apt-get install imagemagick

Compress command mogrify -quality 80% *.jpg

note: this will overwrite images

Software & packages

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


Software & packages

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

 

 

 

 

Software & 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

 

 

 

 

 

Software & packages

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

 

Software & packages

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

Software & packages

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

 

 

Software & packages

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

 

Software & packages

Syncthing

Software & packages

Screen

Screen lets you make virtual terminals with

screen

 

 

New session with name

screen -S session_name

 

Software & packages

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.

Software & packages

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

Software & packages

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

Software & packages

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.

SMB

Editing SMB shares on linux

1 - Open the config file at  /etc/samba/smb.conf

2 -  Edit the SMB file 

[ShareName]
    path = /path/to/share
    browseable = yes
    writable = yes
    valid users = user1, user2

3 - Save & restart smbd

 sudo systemctl restart smbd

SMB Share options

browsable = yes/no Whether the share should be listed when clients request a list of shares. 

Guest ok = yes/no  guests can access without a password, set `read only` to `yes` to just be guest read only `guest ok = yes`

Editing SMB Users

Creating an SMB user sudo smbpasswd -a new_username
Listing all SMB users pdbedit -L
Changing an SMB user's password sudo smbpasswd username


Mounting & Unmounting

Get disk information

List information about all available block devices, including disks and their partitions. 

lsblk -o NAME,MODEL,SIZE,ROTA,TYPE,MOUNTPOINTS

List information about disks and the partitions with UUID

lsblk -f

 List disks with filesystems sudo parted -l

Permanent mounting

Edit the file at /etc/fstab
UUID=xxxxxxxxx-xxxxx-xxxxxxx /mnt/new_drive ext4 defaults X Y

X = Backup order - 0 is no backup

Y = Check filesystem at boot - 0 is no check


after editing fstab:

sudo mount -a

 

 

 

 

Temporary mounting

sudo mount -t ext4 /dev/sdXY /mount/path

X = Disk letter

Y = Disk partition

Symbolic Links

 ln -s /path/to/target /path/to_where_new_shortcut_is_made

Users & Permissions

Users and groups

List all groups and users in groups

getent group

Add a user to a group 

sudo adduser username groupname

which is the same as 

sudo usermod -aG groupname username

Create a new user 

sudo useradd -options_here username

Options available:

Permissions

To see if a particular user can run a command 

sudo -u USER COMMAND

 

To change a folders permission 

sudo chmod -R u+rwx,g+rwx,o-rwx /path/to_folder

or change ownership

sudo chown -R NEW_USER:NEW_GROUP FOLDER_NAME

-R = recursive

 Note: user or group can be left blank

Passwords

To change the curretly logged in user's password passwd 

To change a different users password sudo passwd USERNAME

General Management & commands

Get the size of a folder

ncdu /path/to/view/

Strip all metadata from files

exiftool -all= -overwrite_original *

SSH

Generate a keypair

1 - Generate the key 

ssh-keygen -t ed25519 -a 100 -f $HOME/.ssh/SSH_KEY_NAME

2 - Copy the key to the server

ssh-copy-id -i ~/.ssh/id_rsa.pub USERNAME@SERVER_IP

Fail2Ban

Setup

  1. Update and install apt update && apt upgrade -y && apt install -y fail2ban
  2. Start fail2ban and set it to auto start on boot sudo systemctl start fail2ban && sudo systemctl enable fail2ban 
  3. Make a local copy of the config file (why? because jail.conf get wiped after each update) sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
  4. Open jail.local and setup stuff

Actions

Unbanning IP fail2ban-client set YOUR_JAIL_NAME_HERE unbanip IP_ADDRESS

 Banning IP fail2ban-client JAIL_NAME banip IP_ADDRESS

 Changing max retry attempts

  1. Open file located at etc/fail2ban/jail.local
  2. Add maxretry = x under [sshd]
  3. Restart fail to ban sudo systemctl restart fail2ban

 

A good default

[sshd]
enabled = true
filter = sshd
maxretry = 3
findtime = 1h
bantime = 1y
port    = ssh
logpath = %(sshd_log)s
backend = %(sshd_backend)s
ignoreip = 125.253.46.252

 

wget

General Usage

wget -Flags URL

Full site mirror

wget -mkprE -np -nc -l inf -e robots=off --restrict-file-names=windows -D example.com --no-check-certificate http://example.com

Flags

Bash

Edit shortcuts

Open .bashrc file and add

 alias 1="command"

Auto run commands on terminal open

Open .bashrc file and add command to bottom file

Fix: no bash after ssh login

chsh -s /bin/bash

 


UFW

General Info

By default, UFW is set to deny all incoming connections and allow all outgoing connections. This means anyone trying to reach your server would not be able to connect, while any application within the server would be able to reach the outside world. 

So make sure to allow ssh before enabling UFW!!! UFW requires you to add / deny ports in a certain order, if you want to allow a certain ip access a port but deny access by everyone else, the ip must be allowed first then deny all after


Setup

 sudo apt update && sudo apt install -y ufw

Basic Management

Turning UFW on: 

ufw enable

Turning UFW off: 

ufw disable

Check if UFW is enabled or disabled

sudo ufw status

Allowing access from a particular IP to ANY port

sudo ufw allow from IP_ADDRESS

Allowing access from a partiular IP to A SPECIFIED port:

 sudo ufw allow from IP_ADDRESS to any port PORT_NUMBER

Allowing any access to a specified port:

 sudo ufw allow PORT_NUMBER

Denying access to a specific port 

 sudo ufw deny PORT_NUMBER

List rule numbers

sudo ufw status numbered

 


Swap

Clear swap file

1 - Disable Swap

sudo swapoff -a

2 - Re-enable swap

sudo swapon -a


View Swap usage

sudo smem -rs swap

Swap config

Update swappiness config

sudo bash -c "echo 'vm.swappiness = 1' >> /etc/sysctl.conf"

Update value immediately

sysctl vm.swappiness=1

About swap

Swappiness in Linux is a kernel parameter that controls how aggressively the system moves inactive pages from RAM to the swap space on disk. The swappiness value is represented as a percentage from 0 to 100, and it affects the balance between using physical RAM and swap space. Lowering swappiness can help improve performance in certain scenarios, particularly when you have a decent amount of physical memory available.

How Swappiness Works
Low swappiness values (0–30): The kernel avoids swapping as much as possible and will only swap when RAM is close to being fully utilized. This setting is ideal when performance is prioritized and there’s enough RAM.

High swappiness values (60–100): The kernel uses swap space more aggressively, even when RAM is available. This setting can be helpful on systems with limited RAM or on systems running many applications simultaneously.

The default value for swappiness on most Linux systems is 60.

Disk Stuff

List disks:

 fdisk -l

OR

 df -h


Scanning

1 - Check if SMART scans are enabled

 smartctl -s on /dev/sdX

2 - Run a test

 smartctl -t DURATION /dev/sdX

Duration is either "long" or "short"

3 - Check results

 smartctl -a /dev/sdX


S.M.A.R.T scanning

Scan a disk

sudo smartctl -t long/short /dev/sdX

Check scan results

sudo smartctl -a /dev/sdX

Check scan progress

smartctl -c /dev/sdX

Auto updates

1 - Install and setup unattended-upgrades package

sudo apt update && sudo apt install unattended-upgrades

 

2 - Enable the auto updates

sudo dpkg-reconfigure --priority=low unattended-upgrades

 

3 - Edit the config to allow security updates

Open

sudo nano /etc/apt/apt.conf.d/50unattended-upgrades

 

Uncomment this line

        "${distro_id}:${distro_codename}-updates";

 

SSH Tunnel

ssh -L 127.0.0.1:3389:192.168.4.222:3389 -N conor@cbcore

-N = Don't execute command on remote server
-L = Specifies local port forwarding

Orca IDE server setup

Tested against Orca 1.4.243 (orcad 0.1.0) on Debian 13. Updated 2026-10-28.

Gotchas

  1. The live profile is ~/.orca, not the default ~/.config/orca. Every orca-ide command must have ORCA_USER_DATA_PATH set. Otherwise it quietly starts a second, empty server in ~/.config/orca and you get orcad_instance_lock_held errors.
  2. orca-ide serve gets paused when run in a terminal. It starts bash -ilc to read your environment, and that shell grabs the terminal, so the whole job ends up Stopped. kill -CONT doesn't help. Always run it detached with setsid (see step 2).
  3. Use orca-ide, not orca. /usr/bin/orca is the GNOME screen reader.

Pair a new phone (service already running)

Stop the service, run a pairing server, scan the QR, then start the service again.

# 1. Stop
systemctl --user stop 'orca-serve-*.service'

# 2. Pair
setsid nohup orca-ide serve --mobile-pairing --pairing-address 100.64.0.4 \
  </dev/null >/tmp/orca-serve.log 2>&1 &
sleep 15; cat /tmp/orca-serve.log

Scan the QR with the Orca app, or open the orca://pair?code=... link on the phone. Once the phone shows it's connected:

# 3. Start
pkill -f 'cli/index.js serve'
systemctl --user start orca-serve-3eba5288.service
systemctl --user is-active orca-serve-3eba5288.service   # should print: active

The phone stays paired after the switch back. If cat shows no QR yet, wait a few seconds and run it again.

1. One-time: set the profile path

Put this at the top of ~/.bashrc, above the case $- in ... return block. If it sits below that block, non-interactive SSH sessions (the Orca desktop app) never see it:

export ORCA_USER_DATA_PATH="${ORCA_USER_DATA_PATH:-$HOME/.orca}"

Check it from another machine:

ssh USERNAME@SERVER_NAME 'echo $ORCA_USER_DATA_PATH'   # should print /home/USERNAME/.orca

2. Pair a device

The service runs with --no-pairing, so to pair you stop it and run a pairing server by hand.

systemctl --user stop 'orca-serve-*.service'
pkill -f 'cli/index.js serve'          # any leftover manual server

# Phone (use the Tailscale IP so the phone can reach it)
setsid nohup orca-ide serve --mobile-pairing --pairing-address 100.64.0.4 \
  </dev/null >/tmp/orca-serve.log 2>&1 &

sleep 15; cat /tmp/orca-serve.log      # scan the QR, or open the orca://pair?code=... link

For a desktop or CLI client, leave out --mobile-pairing.

3. Hand back to the service

pkill -f 'cli/index.js serve'
orca-ide serve service install         # writes the unit, enables it, starts it

This creates ~/.config/systemd/user/orca-serve-<hash>.service, which runs orca-ide serve --supervise --no-pairing with ORCA_USER_DATA_PATH set. Linger is already on (loginctl show-user USERNAME --property=Linger), so it starts at boot without anyone logging in.

4. Check it

orca-ide serve service status
ss -tlnp | grep 6768                   # orcad listening on 0.0.0.0:6768
pgrep -af orcad-server.js              # exactly one, running from ~/.orca
ls ~/.config/orca 2>/dev/null || echo "good, no stray profile"

List paired devices:

python3 -c "import json; [print(d['scope'].ljust(8), d['name']) for d in json.load(open('$HOME/.orca/orca-devices.json'))]"

Troubleshooting

Symptom Fix
[1]+ Stopped orca-ide serve ... It was run in a terminal. Kill it with kill -9 -- -<pid> and use the setsid command from step 2
orcad_instance_lock_held Another server owns the profile. Stop the service or pkill -f 'cli/index.js serve'
Error mentions ~/.config/orca ORCA_USER_DATA_PATH isn't set in that shell. Run rm -rf ~/.config/orca and fix step 1
Desktop over SSH shows an empty workspace Same cause: the export is below the interactive check in ~/.bashrc
Service starts then immediately stops The supervisor gave up. Check orca-ide serve service status and run serve service install again
start request repeated too quickly systemctl --user reset-failed 'orca-serve-*.service'
Pairing link rejected The offer expired. Restart the pairing server for a fresh one

Quick reference

orca-ide serve service status | install | uninstall
systemctl --user stop|start 'orca-serve-*.service'
journalctl --user -u 'orca-serve-*.service' -n 50 --no-pager
orca-ide serve restore resume          # un-pause throttled session restores after a reboot