Docs
From Bare Metal to Three Hosted Services
Updated on Aug 13, 2026 at 5:05 AM
A reproducible build order for the box currently running Nextcloud and a small family of WordPress sites — Tailscale-only administration, one reverse proxy, one external drive for data. Skips the dead ends; keeps only what’s actually running.
Click the diagram to enlarge · click outside it to close
Base OS & access
Everything else assumes key-only SSH and a private admin channel exist before any service goes on the box.
Install Ubuntu Server
Standard install (this box runs 26.04 LTS). Use the installer’s LVM-free/plain ext4 layout on the boot disk, create the primary user during setup, and skip any bundled snap extras you don’t need.
If it’s a laptop: disable lid-close suspend
This box is a repurposed laptop. Every default here assumes the lid stays open — closing it suspends the whole machine, taking down every service until someone physically opens it again. Override it explicitly:
sudo mkdir -p /etc/systemd/logind.conf.d
# /etc/systemd/logind.conf.d/lid-switch.conf
[Login]
HandleLidSwitch=ignore
HandleLidSwitchExternalPower=ignore
HandleLidSwitchDocked=ignore
sudo systemctl restart systemd-logind
A drop-in file, not an edit to /etc/systemd/logind.conf directly — same convention as the SSH hardening override below, and survives package upgrades cleanly.
Lock SSH to key-only
Add your public key to ~/.ssh/authorized_keys during first login (cloud-init images often force password auth on until you turn it off), then override it explicitly:
# /etc/ssh/sshd_config.d/10-hardening.conf
PasswordAuthentication no
sudo systemctl reload sshd after confirming key-based login works in a second terminal — don’t close the first one until the second one succeeds.
Don’t assume the installer enabled the SSH service to survive a reboot — verify it explicitly:
systemctl is-enabled ssh # must say "enabled", not just "active"
sudo systemctl enable ssh # if it says disabled
A service that’s merely active right now but not enabled won’t come back after a reboot — on a headless box with no other way in, that’s a real lockout, not a cosmetic gap.
Install Tailscale on the server
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
Note the Tailscale IP it’s assigned (100.x.x.x) — every later step that binds an admin port does so against this address specifically, never 0.0.0.0.
Firewall baseline
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow in on tailscale0 comment 'Trust all Tailscale traffic'
sudo ufw allow 41641/udp comment 'Tailscale direct connections'
sudo ufw allow from 192.168.100.0/24 to any port 22 comment 'LAN SSH fallback'
sudo ufw enable
The LAN-subnet SSH rule is a deliberate fallback for the day Tailscale itself is unreachable (router reboot mid-update, etc.) — not a general-purpose hole. It’s scoped to the local subnet, not the internet.
Ports 80/443 get opened in Phase 4, once there’s a reverse proxy actually listening on them.
Storage
Application data lives on the external drive; the boot NVMe only ever holds the OS and Docker.
Mount the external drive
Find its UUID with sudo blkid, then add a stable fstab entry:
# /etc/fstab
UUID=<drive-uuid> /mnt/nextcloud_data ext4 defaults,noatime,nofail 0 2
nofail matters — without it, a missing external drive at boot can hang the whole system waiting on the mount.
sudo mkdir -p /mnt/nextcloud_data
sudo mount -a
Group convention for shared write access
The Nextcloud container’s process runs as www-data inside the container, which maps to a real www-data user/group on the host. Add your own user to that group up front, so you can write into Nextcloud-owned directories over SSH later without a permissions fight:
sudo usermod -aG www-data $USER
# log out and back in (or open a fresh SSH session) for it to take effect
Group membership is read at login. A shell session opened before this command won’t see the new group — reconnect rather than trying to refresh it in place.
Docker & Nextcloud AIO
One container manages the rest of the Nextcloud stack for you — it just needs to know where the data lives and which ports are safe to expose.
Install Docker Engine
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# reconnect for the group to apply
Run the AIO mastercontainer
This is the one container Nextcloud AIO needs directly — it manages every other Nextcloud container itself via the Docker socket. The two choices that matter: NEXTCLOUD_DATADIR points at the external drive, and the web-admin ports (8080/8443) bind to the Tailscale IP only, never the public interface.
sudo docker run \
--sig-proxy=false \
--name nextcloud-aio-mastercontainer \
--restart always \
--publish <tailscale-ip>:8080:8080 \
--publish <tailscale-ip>:8443:8443 \
--env APACHE_PORT=1100 \
--env NEXTCLOUD_DATADIR=/mnt/nextcloud_data \
--volume nextcloud_aio_mastercontainer:/mnt/docker-aio-config \
--volume /var/run/docker.sock:/var/run/docker.sock:ro \
nextcloud/all-in-one:latest
APACHE_PORT=1100 matters: it moves the internal Apache container off port 80, freeing that port for the reverse proxy set up in the next phase — Apache stays bound to 127.0.0.1 only and is never reached directly.
Complete setup over Tailscale
From any device on the tailnet, open https://<tailscale-ip>:8443 and follow AIO’s own wizard (it issues itself a self-signed cert for this step — that warning is expected). It will pull and start the rest of the stack: the Nextcloud app container, Postgres, Redis, image processing, Talk, etc.
Install Portainer (Docker management UI)
Same private-admin pattern as everything else — bound to the Tailscale IP only, never public:
# ~/docker/portainer/docker-compose.yml
services:
portainer:
image: portainer/portainer-ce:latest
container_name: portainer
restart: always
ports:
- "<tailscale-ip>:9443:9443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./data:/data
cd ~/docker/portainer && docker compose up -d
Portainer locks its own setup screen 5 minutes after first start if no admin account has been created yet (“the instance timed out for security purposes”) — if that happens, docker restart portainer resets the timer.
Recent Portainer versions also require a one-time setup token pasted into the initial admin-creation screen, printed only in the container’s startup logs:
docker logs portainer 2>&1 | grep setup_token
Grab it and create the admin account promptly — both the token and the 5-minute window are freshly (re)issued on every restart.
Reverse proxy & public access
One entry point for the whole internet-facing surface: Nginx Proxy Manager terminates TLS and decides where every request actually goes.
Run Nginx Proxy Manager
On the same Docker network as the Nextcloud containers, so it can reach them by container name:
# ~/docker/npm/docker-compose.yml
services:
npm:
image: 'jc21/nginx-proxy-manager:latest'
container_name: npm
restart: always
ports:
- '80:80'
- '443:443'
- '<tailscale-ip>:81:81'
volumes:
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
networks:
- nextcloud-aio
networks:
nextcloud-aio:
external: true
cd ~/docker/npm && docker compose up -d
Its own admin UI (port 81) is bound to the Tailscale IP the same way AIO’s is — the reverse proxy that fronts the public internet is itself only administrable privately.
Dynamic DNS
A free DuckDNS hostname, kept current by a systemd timer rather than cron (survives reboots cleanly, logs to journald):
# ~/duckdns/duck.sh
echo url="https://www.duckdns.org/update?domains=<yourname>&token=<your-token>&ip=" \
| curl -o ~/duckdns/duck.log -K -
# /etc/systemd/system/duckdns.service
[Unit]
Description=Update DuckDNS IP address
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
User=<you>
ExecStart=/home/<you>/duckdns/duck.sh
# /etc/systemd/system/duckdns.timer
[Unit]
Description=Run DuckDNS update every 5 minutes
[Timer]
OnBootSec=1min
OnUnitActiveSec=5min
Persistent=true
[Install]
WantedBy=timers.target
chmod 700 ~/duckdns/duck.sh
sudo systemctl enable --now duckdns.timer
One DuckDNS account can hold several hostnames under the same token — register one now per service you plan to expose (e.g. one for Nextcloud, one as a hub for everything else).
Router port forward
Forward 80 and 443 only, to the server’s LAN IP. Nothing else — SSH stays off the router entirely, reachable only via Tailscale (and the LAN fallback from Phase 1).
Proxy host + certificate
In NPM’s admin UI: Proxy Hosts → Add Proxy Host — domain name, forward to the Nextcloud Apache container on port 1100, then on the SSL tab request a new Let’s Encrypt certificate and force SSL. NPM handles renewal automatically from there.
Open the firewall for real traffic
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
The full, final rule set looks like this:
| Rule | Purpose |
|---|---|
tailscale0 → allow all | Every private admin surface |
41641/udp → allow | Tailscale direct (NAT-traversed) connections |
22/tcp from LAN subnet → allow | SSH fallback if Tailscale is down |
80/tcp, 443/tcp → allow | Public HTTP/HTTPS, handled entirely by NPM |
| everything else → deny | Default posture |
Adding client devices
The same two-step pattern for every new laptop, phone, or WSL environment that needs to administer the box.
- Install Tailscale on the new device, sign into the same tailnet account.
- Generate a device-specific ed25519 key (
ssh-keygen -t ed25519 -C "device-name") — never reuse one key across devices. - Get the new public key onto the server via a device/session that already has access, appended to
~/.ssh/authorized_keys.
A brand-new device can never authorize itself — step 3 always requires bootstrapping from an existing trusted session. There is no password fallback to fall back on once this is set up, by design.
Bulk media import
The repeatable recipe for getting a large personal archive onto the Nextcloud data drive without going through the (much slower) WebDAV/web upload path.
Open up write access first
Nextcloud’s own files are owned by www-data with the setgid bit set, so new files created underneath inherit the right group — but pre-existing top-level folders may not have group-write set. Fix the destination folder before copying into it:
sudo chmod g+w "/mnt/nextcloud_data/admin/files/<target folder>"
Copy in with rsync, not the web UI
Run from whichever machine actually holds the source files, over SSH to the server:
rsync -a --no-owner --no-group --no-perms --omit-dir-times \
--partial --info=progress2 \
--exclude 'Thumbs.db' --exclude 'desktop.ini' \
--exclude 'System Volume Information' --exclude '$RECYCLE.BIN' \
-e ssh \
"/path/to/source/" \
"user@<tailscale-ip>:/mnt/nextcloud_data/admin/files/<target folder>/"
A non-root SSH user can’t chown/chgrp/set arbitrary timestamps on files it doesn’t own — and some destination folders are pre-existing and owned by www-data, not the connecting user. Skipping ownership/permission/dir-time preservation avoids a wall of harmless-but-noisy errors on every such folder; ownership gets fixed in bulk afterward instead.
It’s safe to re-run the exact same command if a transfer is interrupted — already-copied files are skipped on the size/mtime check, so only the gap gets retried.
Two filesystem limits worth knowing before they surprise you
Unicode normalization. Some sources (old phone exports especially) produce filenames using decomposed Unicode (NFD) — accented/diacritic characters stored as separate combining marks. Nextcloud’s scanner silently refuses to register these (“incompatible encoding”). Fix in bulk with a short walk that renames anything not already in NFC form:
python3 -c "
import os, unicodedata
for dirpath, dirnames, filenames in os.walk('<path>', topdown=False):
for name in filenames + dirnames:
nfc = unicodedata.normalize('NFC', name)
if nfc != name:
os.rename(os.path.join(dirpath, name), os.path.join(dirpath, nfc))
"
Filename length. ext4 caps individual filenames at 255 bytes, not characters — a long title in a multi-byte script (Arabic, CJK, etc.) can exceed that well before it looks long. Rename to something shorter before copying; there’s no way around the filesystem limit.
Fix ownership, then register with Nextcloud
sudo chown -R www-data:www-data "/mnt/nextcloud_data/admin/files/<target folder>"
docker exec --user www-data nextcloud-aio-nextcloud php occ files:scan \
--path="admin/files/<target folder>"
Nextcloud never watches the filesystem for out-of-band changes — anything copied in directly is invisible until this scan runs. The scan report’s Errors column should read 0; anything else is almost always one of the two gotchas above.
WordPress hosting
A whole family of independent WordPress installs, each with its own database, reachable at their own path under one domain and one certificate — a landing page with cards, not a folder of subdomains.
The shape of it
ahaddad-wp.duckdns.org/ is a static cards page (its own tiny nginx:alpine container, no database). /my is another static cards page, one level down. /my/mylogbook and /my/mylearning are full, independent WordPress installs — separate containers, separate MariaDB instances, sharing nothing but the domain and the reverse proxy in front of them. New sites, static or WordPress, slot into the same pattern at any depth.
A WordPress install that knows it lives in a subpath
Two things make a normal WordPress container work correctly under /my/<slug> instead of a domain root: telling WordPress its real URL, and telling Apache to serve that path from its normal document root via an alias.
# apache-subpath.conf
Alias /my/<slug> /var/www/html
<Directory /var/www/html>
AllowOverride All
Require all granted
</Directory>
# docker-compose.yml
services:
wordpress:
image: wordpress:latest
container_name: wordpress-<slug>
restart: always
environment:
WORDPRESS_DB_HOST: wordpress-<slug>-db
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: ${WORDPRESS_DB_PASSWORD}
WORDPRESS_CONFIG_EXTRA: |
define('WP_HOME','https://ahaddad-wp.duckdns.org/my/<slug>');
define('WP_SITEURL','https://ahaddad-wp.duckdns.org/my/<slug>');
volumes:
- ./wp-content:/var/www/html/wp-content
- ./apache-subpath.conf:/etc/apache2/conf-enabled/subpath.conf:ro
networks: [internal, nextcloud-aio]
depends_on: [db]
db:
image: mariadb:11
container_name: wordpress-<slug>-db
restart: always
environment:
MYSQL_DATABASE: wordpress
MYSQL_USER: wordpress
MYSQL_PASSWORD: ${WORDPRESS_DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
volumes: [./db-data:/var/lib/mysql]
networks: [internal]
networks:
internal:
nextcloud-aio:
external: true
Generate the two passwords into .env before starting it — never hard-code them into the compose file itself:
openssl rand -base64 24 | tr -d '/+=' | head -c 32 # run twice, once per secret
Route it in NPM
Every site is one Custom Location on the single existing proxy host — not a new proxy host each time. Proxy Hosts → ahaddad-wp.duckdns.org → Edit → Custom Locations → Add location: path /my/<slug>, forward to wordpress-<slug> on port 80.
Nginx resolves upstream hostnames at save time, not lazily — if the target container isn’t already up and running on the shared network, saving fails with a bare “Internal error” and no useful detail in the UI. Always docker compose up -d the new site first, confirm it’s reachable (docker exec npm curl -s -o /dev/null -w '%{http_code}' http://wordpress-<slug>:80/my/<slug>/), then add the NPM location.
If a save ever does stick in that broken state, it’s fixable directly — NPM stores each proxy host’s custom locations as a JSON column, and the on-disk nginx config is just a generated file:
sudo sqlite3 ~/docker/npm/data/database.sqlite \
"SELECT locations FROM proxy_host WHERE id=<id>;"
# edit the JSON, then write it back:
sudo sqlite3 ~/docker/npm/data/database.sqlite \
"UPDATE proxy_host SET locations='<corrected json>' WHERE id=<id>;"
docker exec npm nginx -t # must say "test is successful"
docker exec npm nginx -s reload
Static sites, the lighter version
No database, no Apache alias trick — just a bind-mounted folder behind nginx:
services:
site:
image: nginx:alpine
container_name: wp-<slug>
restart: always
volumes: [./html:/usr/share/nginx/html:ro]
networks: [nextcloud-aio]
networks:
nextcloud-aio:
external: true
If it’s built by a static-site generator, set its base/public-path build option to /my/<slug> so its own internal links resolve correctly — the build-time equivalent of WP_HOME.
The landing pages
Plain static HTML, one card per site, no build step — edit and the change is live on next request:
<a class="card" href="/my/<slug>">
<h2>Display name</h2>
<p>Short description</p>
</a>
Full system map
The same picture as the top, expanded to show every layer that’s actually running — Docker as its own boundary, Portainer managing it, DuckDNS as a real component rather than a footnote. Then the same map again with the exact configuration behind each box.
Layout
Click the diagram to enlarge · click outside it to close
Every box, labeled
Same map, with the exact address, port, and access method behind each piece.
Edge & DNS — not containers, OS/network level
- Hosts
- ahaddad.duckdns.org, ahaddad-wp.duckdns.org
- Resolves to
- 178.153.184.130 (dynamic)
- Kept current by
- duckdns.timer, every 5 min
- Script
- ~/duckdns/duck.sh
- WAN
- 178.153.184.130 (dynamic)
- Forwards
- 80/tcp, 443/tcp → 192.168.100.13
- Everything else
- not forwarded
- Port
- 22/tcp
- Auth
- key-only (PasswordAuthentication no)
- Allowed from
- tailscale0 (anywhere) + 192.168.100.0/24
- Config
- /etc/ssh/sshd_config.d/10-hardening.conf
Reverse proxy & management
- Image
- jc21/nginx-proxy-manager:latest
- Network
- nextcloud-aio · 172.18.0.12
- Public
- 0.0.0.0:80, 0.0.0.0:443
- Admin
- 100.111.255.36:81
- Host 1
- ahaddad.duckdns.org → nextcloud-aio-apache:1100
- Host 2
- ahaddad-wp.duckdns.org → landing:80, + /my/mylogbook, /my/mylearning
- Image
- portainer/portainer-ce:latest
- Network
- portainer_default · 172.21.0.2
- Admin
- 100.111.255.36:9443
- Mounts
- /var/run/docker.sock (rw) — manages every container on the host
Nextcloud AIO stack — network: nextcloud-aio (172.18.0.0/16)
- IP
- 172.18.0.2
- Admin
- 100.111.255.36:8080, :8443
- Role
- owns docker.sock (ro), manages the other 9 AIO containers
- IP
- 172.18.0.11
- Reached by NPM
- via docker network, port 1100
- Host mapping
- 127.0.0.1:1100 (local debug only)
- IP
- 172.18.0.10
- Port
- 9000, internal only
- IP
- 172.18.0.7
- Port
- 5432, internal only
- IP
- 172.18.0.8
- Port
- 6379, internal only
- IP
- 172.18.0.4
- Public
- 0.0.0.0:3478 tcp+udp (TURN/STUN)
- IPs
- 172.18.0.9, .5, .6, .3
- Ports
- internal only, no host mapping
- Host path
- /mnt/nextcloud_data
- Device
- external drive, ext4, fstab + nofail
WordPress stack
- Image
- nginx:alpine
- IP
- 172.18.0.14 (nextcloud-aio)
- Serves
- / and /my static cards pages
- IP
- 172.18.0.13 (nextcloud-aio) + own “internal” net
- DB
- wordpress-mylogbook-db, mariadb:11, internal-only net
- Path
- /my/mylogbook
- IP
- 172.18.0.15 (nextcloud-aio) + own “internal” net
- DB
- wordpress-mylearning-db, mariadb:11, internal-only net
- Path
- /my/mylearning
Appendix
Where things live, for whoever’s grepping this at 2am.
| Path | What |
|---|---|
/mnt/nextcloud_data | External drive, all Nextcloud user data |
~/docker/npm/ | Nginx Proxy Manager compose + data + certs |
~/docker/wp-landing/ | Static cards pages (root and each sub-hub) |
~/docker/wp-<slug>/ | One directory per WordPress/static site, fully independent |
~/docker/portainer/ | Portainer compose + data — Docker management UI, Tailscale-only |
~/docker/ADDING-A-SITE.md | Living step-by-step for the next new site |
~/duckdns/duck.sh | Dynamic DNS updater, run by duckdns.timer |
/etc/ssh/sshd_config.d/10-hardening.conf | Key-only SSH override |
Quick health check, any time
docker ps --format "table {{.Names}}\t{{.Status}}"
sudo ufw status verbose
tailscale status
docker exec npm nginx -t
Reboot resilience check
Worth running once after any fresh build, and again any time a service was set up by hand rather than through this guide — active only means it’s running now, not that it’ll come back after a reboot:
for s in docker tailscaled ssh ufw duckdns.timer; do
echo "$s: $(systemctl is-enabled $s 2>&1)"
done
Every line should read enabled. Container-level survival doesn’t need separate checking — anything with restart: always or unless-stopped in its compose file (everything in this build) comes back automatically the moment the Docker daemon itself starts, no matter how the box went down.