Linux
- General Info
- Web server management
- Apache setup & Virtual servers
- SQL
- SSL & Certificate management
- PostgreSQL
- SQL Reference
- Mailcow Dockerized Setup Guide (Home Server + VPS Proxy + Tailscale)
- Software & packages
- Image manipulation
- yt-dlp
- Python packages and commands
- spotdl
- ffmpeg
- IPFS
- Wireguard
- Node & NVM
- Syncthing
- Screen
- Tailscale
- smb / samba
- autofs
- Headscale
- SMB
- Mounting & Unmounting
- Symbolic Links
- Users & Permissions
- General Management & commands
- SSH
- Fail2Ban
- wget
- Bash
- UFW
- Swap
- Disk Stuff
- Auto updates
- SSH Tunnel
- Orca IDE server setup
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
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
certbot certificatescertbot delete --cert-name CERT_NAME_HEREManaging the apache2 service
Start, stop & restart sudo service apache2 start/restart/stop
sudo a2ensite SITE_CONF_FILE.confsudo a2dissite SITE_CONF_FILE.confSQL
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
- Open file at
/etc/mysql/maridadb.conf.d/50-server.cnf - Edit line
bind-address = 127.0.0.1to ->bind-address = 0.0.0.0 - Restart db service
systemctl restart mariadb - 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
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
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
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
);
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:
- Outbound must be relayed through the VPS (Section 9). The home server is behind CGNAT, so mail sent directly would leave from the ISP's shared IP — wrong PTR, no SPF match, port 25 likely blocked. Relaying through the VPS makes the sending IP match
mail.seabee.me. - Inbound must carry the client's real IP via PROXY protocol (Section 8), otherwise every connection looks like it came from the VPS and mailcow's fail2ban will ban your own relay.
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)
- 2+ CPU cores (4+ for production/multiple domains)
- 6 GB RAM minimum + 1 GB swap (8 GB recommended if running ClamAV/full-text search)
- 20 GB disk minimum, more for mailbox storage
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
-
The A record and CNAME records go under Advanced DNS → Host Records (Add New Record).
-
The MX record goes under Advanced DNS → Mail Settings → Custom MX, filled in as:
Field Value Type MX Record Host @Value mail.seabee.me.Priority 10TTL Automatic -
Important: the MX record only resolves correctly if the
mailA record above already exists — MX points to a hostname, not an IP directly. -
Check Host Records for a stray CNAME on
@(bare domain) — Namecheap gives CNAME priority over MX on the same host, which silently breaks mail delivery if one exists.
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)
dig +short mail.seabee.mereturns 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 atmail.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:
- Mailcow hostname → enter
mail.seabee.me(must match your DNSA/MXrecords) - Timezone
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 — sync Caddy's cert into mailcow (this guide's default). Caddy on the VPS already holds a valid Let's Encrypt cert for
mail.seabee.me. cbcore pulls that cert over Tailscale, drops it into mailcow, and restarts the mail services. No DNS-provider API needed, nothing changes at your registrar. RequiresSKIP_LETS_ENCRYPT=y(Section 4). - Option A — DNS-01 (documented at the end of this section): mailcow gets its own cert by writing a DNS TXT record. Cleaner and fully self-contained, but only if your DNS provider has a usable API — Namecheap's is gated and IPv4-whitelist-only, which doesn't work from a CGNAT box, so Option B is the default here.
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 theprintffor acurlto 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:
- HTTP is the exception. The web UI is bound with the separate
HTTP_BIND/HTTPS_BIND+HTTP_PORT/HTTPS_PORTpairs (Section 4), not the singleIP:PORTform — a mailcow quirk, for technical reasons http is handled differently from the mail services. - This disables IPv6 for those services. Per the mailcow docs, specifying an explicit IPv4 address skips all IPv6 bindings for that service since Docker 20.x. Fine here —
ENABLE_IPV6=falsealready. - Local-only ports stay put.
DOVEADM_PORT,SQL_PORTandREDIS_PORTremain on their127.0.0.1defaults. Don't scope these to Tailscale. - LAN clients: the
IP:PORTvariable takes a single address. If you also want phones/laptops on your home WiFi to reach the mail ports directly (not via the VPS), that's the one case where you'd add adocker-compose.override.yml— a second, LAN-IP binding per port. Do it deliberately, and remember the merge behaviour above: those override bindings are added to themailcow.confones, so leave theIP:PORTvariable on the Tailscale IP and let the override supply only the extra LAN binding.
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:
HTTP_REDIRECT=ninmailcow.conf(Section 4) — otherwise mailcow's nginx 301-redirects every proxied request to HTTPS, producing an infinite loop (ERR_TOO_MANY_REDIRECTS). It ignoresX-Forwarded-Protofor this, so the header alone won't save you; the redirect has to be disabled.ADDITIONAL_SERVER_NAMES=mail.seabee.me(Section 4) — so mailcow serves the right vhost instead of falling back to its internal address (which makes SOGo emithttps://100.64.0.3:8443/...URLs that hang for anyone off your tailnet).header_up X-Forwarded-Proto httpsbelow — tells mailcow the public side is HTTPS even though the Caddy→cbcore hop is plain HTTP.
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 build100.64.0.3:8443resource URLs — fine on your tailnet (the IP resolves), but they hang forever for real external users. The 8005 +HTTP_REDIRECT=ncombination above is the correct one.
Verify no loop after reloading, from a machine (or
curl) —curl -skI https://mail.seabee.me/should returnHTTP/2 200, not301 → https://mail.seabee.me/. And test webmail login from off your tailnet (phone on mobile data), since a tailnet machine can reach100.64.0.3:8443directly 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:
- Binds scoped to the VPS public IP, not
*.bind *:25occupies port 25 on every interface including Tailscale — which collides with the outbound relay Postfix in Section 9 (it needs100.64.0.2:25). Scoping to the public IP keeps inbound and outbound cleanly separated. - Plain passthrough to start — no
send-proxyyet.check send-proxymakes 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). mode tcp+ long timeouts indefaults. This HAProxy only forwards mail, sodefaultsis 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 HTTPerrorfiles from the stock Debian config are dropped; they only apply inmode 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 theextra.*files — check the current method against docs.mailcow.email and your runningdata/conf/postfix/master.cfrather 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.cfline only covers port 25 (postscreen). If you enablesend-proxy-v2on 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-servicemaster.cf/listener flags), restart the containers, and only then run thesend-proxy-v2sedfrom 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
postconfand the Debianpostfixman 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 itselfmail.seabee.me, then when cbcore hands it a message and the relay greets cbcore withHELO mail.seabee.me, cbcore's Postfix sees its own hostname coming back and aborts withmail for [100.64.0.2]:25 loops back to myself— mail never leaves. So the relay usesvps.seabee.mefor bothmyhostnameandsmtp_helo_name. (This is also why the PTR isvps.seabee.mein 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:
- Host:
[100.64.0.2]:25— the square brackets disable MX lookup so it goes straight to that host - Username / Password: leave blank (the VPS trusts cbcore by Tailscale IP; no SASL)
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 saysmail for [100.64.0.2]:25 loops back to myself, the relay is sharing cbcore's hostname — checksudo postconf myhostname smtp_helo_nameon the VPS readsvps.seabee.me, notmail.seabee.me(see the loop note in 9a). Watch the actual handoff on cbcore withdocker compose logs -f postfix-mailcowwhile sending: a healthy send showsrelay=100.64.0.2[100.64.0.2]:25 ... status=sent; the loop showsgreeted 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:
- 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. - Add a mailbox. Mailboxes → add
you@seabee.mewith a password. This is the account you send/receive as. - Now generate DKIM (Configuration → ARC/DKIM keys): pick
seabee.me, selectordkim, 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. - Attach the outbound relay (Section 9b): Mail Setup → Domains → edit
seabee.me→ set the Sender-Dependent Transport to your[100.64.0.2]:25relay.
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 listseabee.me), and... HGET DKIM_PUB_KEYS seabee.meprints thev=DKIM1;...TXT record to publish.
11. Post-install checklist
- DKIM: generate the key (step 3 above), then publish the TXT record it gives you — Host
dkim._domainkey(Namecheap appends the domain; do not type the fulldkim._domainkey.seabee.me), Value the fullv=DKIM1;...string - Change the default admin password (step 10)
- Confirm outbound relay works (Section 9) — mail-tester should show the VPS as the sending IP with SPF/DKIM/DMARC all passing
- Confirm the mail-port cert is real (Section 4a) — the
openssl s_clientcheck in Section 12 should show Let's Encrypt, not the mailcow snake-oil CA; confirm themailcow-cert-pull.timeris enabled (systemctl list-timers | grep cert-pull) - Confirm webmail loads off-tailnet — log into
https://mail.seabee.me/from a phone on mobile data (Tailscale off) and check it stays onmail.seabee.me(depends onHTTP_REDIRECT=n+ADDITIONAL_SERVER_NAMES+ the 8005 Caddy backend from Sections 4/8) - DMARC: start at
p=none(Section 2), confirm a mail-tester send passes SPF+DKIM+DMARC, then tightenp=none→p=quarantine→p=reject - Fail2Ban: built into Mailcow, verify it's enabled in the admin panel
- Watchdog notifications: configure so you get alerted if a component goes down
- Updates: run
./update.shfrom/home/conor/Docker/mailcowmonthly at minimum; more often for security patches. Because all your interface bindings now live inmailcow.conf(not a compose override),./update.shcan overwritedocker-compose.ymlfreely without touching your port config.
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:
- MX Toolbox — MX, SPF, blacklist check
- Mail-tester.com — send a test email, get a deliverability score covering SPF/DKIM/DMARC
- Send to
check-auth@verifier.port25.comfor a raw authentication report
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
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
- aqlalchemy - SQL Alchemy
- snowflake-sqlalchemy - SQL Alchemy for snowflake
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
- Samba passwords and Linux system passwords are independent — changing one doesn't change the other.
- For a multi-user setup, create a dedicated group (e.g.,
sambashare) and usevalid users = @sambasharein the share definition. - If you're running this on the same machine as your wiki app (e.g., WikiJS, Obsidian vault server), Samba is a good way to access the underlying vault files directly from other machines for editing.
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
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=fuseand:sshfs#user@hostformat. This does not work on modern Linux kernels. Always usefstype=fuse.sshfswith plainuser@host:/pathsyntax.
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:
- The key file must be readable by root
- Root must have the remote host in its known_hosts — or
StrictHostKeyChecking=nomust 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
- autofs mounts do not appear in
dformountoutput unless currently active — this is normal. /etc/auto.masterchanges require a fullsystemctl restart autofs; map file changes only needsystemctl reload autofs.- For Ansible management across your fleet, the map files and master config are straightforward to template — a single playbook can deploy consistent autofs config to all five machines.
Headscale
This supersedes the original guide. Changes from that version, all based on what you actually have running:
- Caddy is a native systemd service on the host, not a Docker container — the original guide's
caddyservice block indocker-compose.ymland the Docker-network Caddyfile no longer apply. server_urlhad the internal port (:8080) wrongly appended — fixed.trusted_proxieswas missing entirely — added, using127.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).dns.base_domainwas left as the placeholderexample.com— fixed to a subdomain distinct fromserver_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
sudo smbpasswd -a new_usernamepdbedit -L sudo smbpasswd usernameMounting & 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
/etc/fstabUUID=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
Regular SymLink
ln -s /path/to/target /path/to_where_new_shortcut_is_made
-s = softlink like a windows shortcut
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:
- m = create home directory
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
- Update and install
apt update && apt upgrade -y && apt install -y fail2ban - Start fail2ban and set it to auto start on boot
sudo systemctl start fail2ban && sudo systemctl enable fail2ban - 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 - 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
- Open file located at
etc/fail2ban/jail.local - Add
maxretry = xunder[sshd] - 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
--mirror / -mTurns on all options to make a mirror copy of a site (`--recursive`, `--timestamping`, `--level=inf`, `--no-remove-listing`)--convert-links / -kConverts the links in the downloaded documents to make them suitable for offline viewing. This means that all the links will point to the local files instead of the original URLs.adjust-extension / -EAdjusts the file extension based on the MIME type. For example, if a file is served as HTML but doesn't have an .html extension, this flag will rename the file to include the .html extension--page-requisites / -pDownloads all the resources that a page requires to display properly, such as images, CSS, and JavaScript files.--restrict-file-names=windowsModifies filenames so that they are compatible with Windows. This avoids issues with characters that are not allowed in Windows filenames (e.g : or * ). `--domains example.com`: - Restricts the download to the specified domain (example.com in this case). wget will only download files from this domain and no other domains.--no-clobber / -ncPrevents wget from overwriting existing files. If a file already exists, wget will skip the download of that file.--no-check-certificateDisables SSL/TLS certificate validation. This can be useful if the server's certificate is self-signed or otherwise not trusted by default.-e robots=offIgnores the robots.txt file on the server, allowing wget to download pages and resources that might otherwise be disallowed by the server's robots.txt file. Use this option responsibly.--recursive / -rEnables recursive downloading. This means wget will follow links found in the downloaded files and download those files as well. This is necessary for downloading entire websites or sections of websites.--level=infSpecifies the maximum recursion depth. Setting it to inf (infinity) means wget will follow links at any depth, effectively downloading the entire site as long as links are found.--no-parent / -npPrevents wget from following links outside the specified directory. This means it won't ascend to parent directories but will stay within the directory structure of the specified URL.
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
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
- The live profile is
~/.orca, not the default~/.config/orca. Everyorca-idecommand must haveORCA_USER_DATA_PATHset. Otherwise it quietly starts a second, empty server in~/.config/orcaand you getorcad_instance_lock_helderrors. orca-ide servegets paused when run in a terminal. It startsbash -ilcto read your environment, and that shell grabs the terminal, so the whole job ends upStopped.kill -CONTdoesn't help. Always run it detached withsetsid(see step 2).- Use
orca-ide, notorca./usr/bin/orcais 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.
- Each offer pairs one device and expires quickly. For the next device, run
pkill -f 'cli/index.js serve'and start it again. - Pairings persist across restarts and reboots.
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