Loading…

Docs

From Bare Metal to Three Hosted Services

Docs Setup Ubuntu Guides Web Other WordPress
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.

Home Server Build Guide — Ubuntu → Nextcloud → WordPress

Click the diagram to enlarge · click outside it to close

Two independent paths into the box. Public HTTP/HTTPS is the only thing the router forwards, and Nginx Proxy Manager is the only thing that ever receives it — it alone decides whether a request goes to Nextcloud or a WordPress site. Every administrative surface (SSH, NPM’s own admin UI, the AIO admin panel) is bound to the Tailscale interface specifically, so it’s reachable only from devices already on the tailnet, from anywhere in the world, without ever being exposed to the router.
01

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.

Gotcha

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
Why

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.

02

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
Gotcha

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.

03

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
Gotcha

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.

04

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:

RulePurpose
tailscale0 → allow allEvery private admin surface
41641/udp → allowTailscale direct (NAT-traversed) connections
22/tcp from LAN subnet → allowSSH fallback if Tailscale is down
80/tcp, 443/tcp → allowPublic HTTP/HTTPS, handled entirely by NPM
everything else → denyDefault posture
05

Adding client devices

The same two-step pattern for every new laptop, phone, or WSL environment that needs to administer the box.

  1. Install Tailscale on the new device, sign into the same tailnet account.
  2. Generate a device-specific ed25519 key (ssh-keygen -t ed25519 -C "device-name") — never reuse one key across devices.
  3. Get the new public key onto the server via a device/session that already has access, appended to ~/.ssh/authorized_keys.
Note

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.

06

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>/"
Why these flags

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

Gotcha

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.

07

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.

Gotcha

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>
08

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

Everything left of the “ubuntu-server” boundary is off-box. DuckDNS isn’t in the traffic path itself — a systemd timer on the server pushes its current public IP to DuckDNS every 5 minutes, and that’s what browsers resolve against before ever reaching the router. Inside the server, everything except SSH runs as a Docker container, and Portainer (also a container) manages that entire layer through the Docker socket rather than through any of the app-level routing.

Every box, labeled

Same map, with the exact address, port, and access method behind each piece.

Edge & DNS — not containers, OS/network level

DuckDNS
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
Router
WAN
178.153.184.130 (dynamic)
Forwards
80/tcp, 443/tcp → 192.168.100.13
Everything else
not forwarded
SSH🔒 restricted
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

npm🔒 :81
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
portainer🔒 :9443
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)

mastercontainer🔒 :8080/:8443
IP
172.18.0.2
Admin
100.111.255.36:8080, :8443
Role
owns docker.sock (ro), manages the other 9 AIO containers
apache
IP
172.18.0.11
Reached by NPM
via docker network, port 1100
Host mapping
127.0.0.1:1100 (local debug only)
nextcloud (app)
IP
172.18.0.10
Port
9000, internal only
database (postgres)
IP
172.18.0.7
Port
5432, internal only
redis
IP
172.18.0.8
Port
6379, internal only
talk
IP
172.18.0.4
Public
0.0.0.0:3478 tcp+udp (TURN/STUN)
imaginary · notify-push · whiteboard · eurooffice
IPs
172.18.0.9, .5, .6, .3
Ports
internal only, no host mapping
data directory
Host path
/mnt/nextcloud_data
Device
external drive, ext4, fstab + nofail

WordPress stack

wp-landing
Image
nginx:alpine
IP
172.18.0.14 (nextcloud-aio)
Serves
/ and /my static cards pages
wordpress-mylogbook (+ db)
IP
172.18.0.13 (nextcloud-aio) + own “internal” net
DB
wordpress-mylogbook-db, mariadb:11, internal-only net
Path
/my/mylogbook
wordpress-mylearning (+ db)
IP
172.18.0.15 (nextcloud-aio) + own “internal” net
DB
wordpress-mylearning-db, mariadb:11, internal-only net
Path
/my/mylearning
A

Appendix

Where things live, for whoever’s grepping this at 2am.

PathWhat
/mnt/nextcloud_dataExternal 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.mdLiving step-by-step for the next new site
~/duckdns/duck.shDynamic DNS updater, run by duckdns.timer
/etc/ssh/sshd_config.d/10-hardening.confKey-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.

Written from the box’s actual running configuration, not from memory of how it was set up — every command here was cross-checked against what’s live. Placeholders like <slug> and <tailscale-ip> stand in for values specific to your own build.