{"id":634,"date":"2026-08-12T17:52:01","date_gmt":"2026-08-12T17:52:01","guid":{"rendered":"https:\/\/ahaddad-wp.duckdns.org\/my\/mylogbook\/?post_type=doc_article&#038;p=634"},"modified":"2026-08-13T21:26:55","modified_gmt":"2026-08-13T18:26:55","slug":"from-bare-metal-to-three-hosted-services","status":"publish","type":"doc_article","link":"https:\/\/ahaddad-wp.duckdns.org\/my\/mylogbook\/docs\/from-bare-metal-to-three-hosted-services\/","title":{"rendered":"From Bare Metal to Three Hosted Services"},"content":{"rendered":"\n<pre class=\"wp-block-code\"><code>Updated on Aug 13, 2026 at 5:05 AM<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A reproducible build order for the box currently running Nextcloud and a small family of WordPress sites \u00e2\u20ac\u201d Tailscale-only administration, one reverse proxy, one external drive for data. Skips the dead ends; keeps only what&#8217;s actually running.<\/p>\n\n\n\n<title>Home Server Build Guide \u2014 Ubuntu \u2192 Nextcloud \u2192 WordPress<\/title>\n<meta name=\"description\" content=\"Reproducible build guide: bare Ubuntu install through Tailscale, Nextcloud AIO, media import, and path-routed WordPress hosting.\">\n<style>\n  \/* ---------- tokens ---------- *\/\n  :root {\n    --bg: #eef1f4;\n    --surface: #ffffff;\n    --surface-2: #e4e9ee;\n    --fg: #1b2430;\n    --muted: #5b6675;\n    --border: #d3dae1;\n    --accent: #a8631f;\n    --accent-fg: #ffffff;\n    --code-fg: #2b3644;\n    --note-bg: #fdf3e9;\n    --note-border: #d99a4e;\n    --shadow: 0 1px 2px rgba(20,30,45,0.06), 0 8px 24px rgba(20,30,45,0.05);\n  }\n  @media (prefers-color-scheme: dark) {\n    :root:not([data-theme=\"light\"]) {\n      --bg: #0f1620;\n      --surface: #17202c;\n      --surface-2: #1c2635;\n      --fg: #e6ebf1;\n      --muted: #8b98a9;\n      --border: #2a3648;\n      --accent: #d99a4e;\n      --accent-fg: #241505;\n      --code-fg: #dbe4ee;\n      --note-bg: #241c10;\n      --note-border: #b87a30;\n      --shadow: 0 1px 2px rgba(0,0,0,0.3), 0 8px 24px rgba(0,0,0,0.35);\n    }\n  }\n  :root[data-theme=\"dark\"] {\n    --bg: #0f1620;\n    --surface: #17202c;\n    --surface-2: #1c2635;\n    --fg: #e6ebf1;\n    --muted: #8b98a9;\n    --border: #2a3648;\n    --accent: #d99a4e;\n    --accent-fg: #241505;\n    --code-fg: #dbe4ee;\n    --note-bg: #241c10;\n    --note-border: #b87a30;\n    --shadow: 0 1px 2px rgba(0,0,0,0.3), 0 8px 24px rgba(0,0,0,0.35);\n  }\n\n  * { box-sizing: border-box; }\n  html { -webkit-text-size-adjust: 100%; }\n  body {\n    margin: 0;\n    background: var(--bg);\n    color: var(--fg);\n    font-family: Charter, \"Iowan Old Style\", Georgia, Cambria, serif;\n    font-size: 17px;\n    line-height: 1.6;\n    -webkit-font-smoothing: antialiased;\n  }\n  ::selection { background: var(--accent); color: var(--accent-fg); }\n\n  a { color: var(--accent); }\n  a:focus-visible, button:focus-visible, summary:focus-visible {\n    outline: 2px solid var(--accent);\n    outline-offset: 2px;\n  }\n\n  code, pre, kbd, .mono {\n    font-family: ui-monospace, \"SF Mono\", \"Cascadia Code\", \"JetBrains Mono\", Consolas, \"Roboto Mono\", monospace;\n  }\n\n  \/* ---------- layout shell ---------- *\/\n  .page {\n    max-width: 1180px;\n    margin: 0 auto;\n    padding: 0 1.5rem 6rem;\n    display: grid;\n    grid-template-columns: 230px minmax(0, 72ch);\n    gap: 3.5rem;\n    align-items: start;\n  }\n  @media (max-width: 900px) {\n    .page { grid-template-columns: 1fr; }\n    nav.toc { position: static !important; }\n  }\n\n  header.hero {\n    grid-column: 1 \/ -1;\n    padding: 4.5rem 0 2.5rem;\n    border-bottom: 1px solid var(--border);\n  }\n  header.hero .eyebrow {\n    font-family: ui-monospace, \"SF Mono\", monospace;\n    font-size: 0.78rem;\n    letter-spacing: 0.08em;\n    text-transform: uppercase;\n    color: var(--accent);\n    margin: 0 0 0.9rem;\n  }\n  header.hero h1 {\n    font-size: clamp(1.9rem, 3.4vw, 2.6rem);\n    line-height: 1.15;\n    margin: 0 0 0.75rem;\n    text-wrap: balance;\n    max-width: 22ch;\n  }\n  header.hero p.lede {\n    font-size: 1.1rem;\n    color: var(--muted);\n    max-width: 58ch;\n    margin: 0;\n  }\n\n  nav.toc {\n    position: sticky;\n    top: 2.5rem;\n    font-family: ui-monospace, \"SF Mono\", monospace;\n    font-size: 0.83rem;\n    line-height: 1.5;\n  }\n  nav.toc .toc-label {\n    text-transform: uppercase;\n    letter-spacing: 0.08em;\n    color: var(--muted);\n    margin-bottom: 0.9rem;\n    display: block;\n  }\n  nav.toc ol {\n    list-style: none;\n    margin: 0;\n    padding: 0;\n    counter-reset: phase;\n  }\n  nav.toc li { margin-bottom: 0.55rem; counter-increment: phase; }\n  nav.toc a {\n    text-decoration: none;\n    color: var(--fg);\n    display: flex;\n    gap: 0.5em;\n  }\n  nav.toc a:hover { color: var(--accent); }\n  nav.toc a::before {\n    content: counter(phase, decimal-leading-zero);\n    color: var(--muted);\n  }\n\n  main { min-width: 0; }\n\n  section.phase {\n    padding-top: 3.5rem;\n    margin-top: 3.5rem;\n    border-top: 1px solid var(--border);\n    scroll-margin-top: 1.5rem;\n  }\n  section.phase:first-of-type { border-top: none; margin-top: 2.5rem; padding-top: 0; }\n\n  .phase-head {\n    display: flex;\n    align-items: baseline;\n    gap: 0.9rem;\n    margin-bottom: 0.4rem;\n  }\n  .phase-num {\n    font-family: ui-monospace, \"SF Mono\", monospace;\n    color: var(--accent);\n    font-size: 1rem;\n    font-weight: 600;\n  }\n  section.phase h2 {\n    font-size: 1.55rem;\n    margin: 0;\n    text-wrap: balance;\n  }\n  section.phase > p.dek {\n    color: var(--muted);\n    margin: 0.3rem 0 1.8rem;\n    max-width: 60ch;\n  }\n\n  h3.step {\n    font-size: 1.05rem;\n    margin: 2.1rem 0 0.7rem;\n    display: flex;\n    align-items: center;\n    gap: 0.6rem;\n  }\n  h3.step .dot {\n    width: 6px; height: 6px; border-radius: 50%;\n    background: var(--accent);\n    flex: none;\n  }\n\n  p { margin: 0.9rem 0; }\n  ul, ol { padding-left: 1.3rem; }\n  li { margin: 0.35rem 0; }\n\n  pre {\n    background: var(--surface-2);\n    border: 1px solid var(--border);\n    border-radius: 8px;\n    padding: 0.9rem 1.1rem;\n    overflow-x: auto;\n    font-size: 0.86rem;\n    line-height: 1.55;\n    color: var(--code-fg);\n    margin: 0.9rem 0 1.3rem;\n  }\n  code { font-size: 0.88em; color: var(--code-fg); }\n  p code, li code {\n    background: var(--surface-2);\n    border: 1px solid var(--border);\n    border-radius: 4px;\n    padding: 0.1em 0.35em;\n  }\n  pre code { background: none; border: none; padding: 0; }\n\n  .note {\n    display: grid;\n    grid-template-columns: auto 1fr;\n    gap: 0.7rem;\n    background: var(--note-bg);\n    border: 1px solid var(--note-border);\n    border-radius: 8px;\n    padding: 0.9rem 1.1rem;\n    margin: 1.2rem 0;\n    font-size: 0.95rem;\n  }\n  .note .tag {\n    font-family: ui-monospace, \"SF Mono\", monospace;\n    font-size: 0.72rem;\n    letter-spacing: 0.06em;\n    text-transform: uppercase;\n    color: var(--note-border);\n    align-self: start;\n    padding-top: 0.15rem;\n    white-space: nowrap;\n  }\n  .note-body { min-width: 0; }\n  .note-body p { margin: 0; }\n  .note-body p + p { margin-top: 0.5rem; }\n  .note-body pre {\n    margin: 0.6rem 0;\n    font-size: 0.82rem;\n  }\n  .note-body pre:first-child { margin-top: 0; }\n  .note-body pre:last-child { margin-bottom: 0; }\n\n  table {\n    width: 100%;\n    border-collapse: collapse;\n    font-size: 0.92rem;\n    margin: 1rem 0 1.5rem;\n  }\n  th, td {\n    text-align: left;\n    padding: 0.5rem 0.7rem;\n    border-bottom: 1px solid var(--border);\n    vertical-align: top;\n  }\n  th {\n    font-family: ui-monospace, \"SF Mono\", monospace;\n    font-size: 0.75rem;\n    text-transform: uppercase;\n    letter-spacing: 0.05em;\n    color: var(--muted);\n    font-weight: 600;\n  }\n  td code { white-space: nowrap; }\n\n  figure { margin: 1.5rem 0 2rem; }\n  figure svg {\n    max-width: 100%;\n    height: auto;\n    display: block;\n  }\n  figcaption {\n    font-size: 0.85rem;\n    color: var(--muted);\n    margin-top: 0.7rem;\n    max-width: 60ch;\n  }\n\n  \/* ---------- click-to-enlarge (CSS-only, no JS) ---------- *\/\n  .zoom-toggle {\n    position: absolute;\n    opacity: 0;\n    pointer-events: none;\n  }\n  .zoom-backdrop {\n    display: none;\n    position: fixed;\n    inset: 0;\n    background: rgba(10, 14, 20, 0.72);\n    z-index: 40;\n    cursor: zoom-out;\n  }\n  .zoom-toggle:checked ~ .zoom-backdrop { display: block; }\n  figure.zoomable .zoom-trigger {\n    display: block;\n    cursor: zoom-in;\n    border-radius: 10px;\n  }\n  figure.zoomable .zoom-trigger:hover { outline: 2px solid var(--accent); outline-offset: 4px; }\n  figure.zoomable .zoom-hint {\n    font-family: ui-monospace, \"SF Mono\", monospace;\n    font-size: 0.72rem;\n    color: var(--muted);\n    margin-top: 0.5rem;\n  }\n  .zoom-toggle:checked ~ figure.zoomable {\n    position: fixed;\n    top: 50%;\n    left: 50%;\n    transform: translate(-50%, -50%);\n    z-index: 41;\n    width: min(95vw, 1180px);\n    max-height: 92vh;\n    overflow: auto;\n    background: var(--surface);\n    border: 1px solid var(--border);\n    border-radius: 12px;\n    padding: 1.6rem 1.8rem;\n    box-shadow: 0 24px 70px rgba(0,0,0,0.45);\n    margin: 0;\n  }\n  .zoom-toggle:checked ~ figure.zoomable .zoom-trigger { cursor: zoom-out; }\n  .zoom-toggle:checked ~ figure.zoomable .zoom-hint { display: none; }\n  @media (prefers-reduced-motion: no-preference) {\n    figure.zoomable .zoom-trigger { transition: outline-color 0.15s ease; }\n  }\n\n  footer.colophon {\n    grid-column: 1 \/ -1;\n    margin-top: 4rem;\n    padding-top: 1.8rem;\n    border-top: 1px solid var(--border);\n    color: var(--muted);\n    font-size: 0.85rem;\n  }\n\n  .placeholder { color: var(--accent); font-style: normal; }\n\n  \/* ---------- spec cards (detailed config reference) ---------- *\/\n  h4.group {\n    font-family: ui-monospace, \"SF Mono\", monospace;\n    font-size: 0.78rem;\n    letter-spacing: 0.07em;\n    text-transform: uppercase;\n    color: var(--muted);\n    margin: 2.2rem 0 0.9rem;\n  }\n  h4.group:first-of-type { margin-top: 1.6rem; }\n  .spec-grid {\n    display: grid;\n    grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));\n    gap: 0.9rem;\n    margin-bottom: 0.5rem;\n  }\n  .spec-card {\n    background: var(--surface);\n    border: 1px solid var(--border);\n    border-radius: 10px;\n    padding: 1rem 1.1rem;\n    box-shadow: var(--shadow);\n  }\n  .spec-card .name {\n    font-family: ui-monospace, \"SF Mono\", monospace;\n    font-size: 0.92rem;\n    font-weight: 600;\n    color: var(--fg);\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    gap: 0.5rem;\n    margin-bottom: 0.6rem;\n  }\n  .spec-card .lock {\n    font-family: ui-monospace, monospace;\n    font-size: 0.66rem;\n    letter-spacing: 0.03em;\n    color: var(--accent);\n    border: 1px solid var(--accent);\n    border-radius: 4px;\n    padding: 0.1rem 0.4rem;\n    white-space: nowrap;\n  }\n  .spec-card dl {\n    margin: 0;\n    font-size: 0.83rem;\n    display: grid;\n    grid-template-columns: auto 1fr;\n    column-gap: 0.6rem;\n    row-gap: 0.35rem;\n  }\n  .spec-card dt {\n    color: var(--muted);\n    font-family: ui-monospace, \"SF Mono\", monospace;\n    font-size: 0.72rem;\n    text-transform: uppercase;\n    letter-spacing: 0.03em;\n    padding-top: 0.1rem;\n    white-space: nowrap;\n  }\n  .spec-card dd {\n    margin: 0;\n    font-family: ui-monospace, \"SF Mono\", monospace;\n    color: var(--code-fg);\n    word-break: break-word;\n  }\n<\/style>\n\n<div class=\"page\">\n  <nav class=\"toc\">\n    <span class=\"toc-label\">Contents<\/span>\n    <ol>\n      <li><a href=\"#phase-1\">Base OS &amp; access<\/a><\/li>\n      <li><a href=\"#phase-2\">Storage<\/a><\/li>\n      <li><a href=\"#phase-3\">Docker &amp; Nextcloud AIO<\/a><\/li>\n      <li><a href=\"#phase-4\">Reverse proxy &amp; public access<\/a><\/li>\n      <li><a href=\"#phase-5\">Adding client devices<\/a><\/li>\n      <li><a href=\"#phase-6\">Bulk media import<\/a><\/li>\n      <li><a href=\"#phase-7\">WordPress hosting<\/a><\/li>\n      <li><a href=\"#system-map\">Full system map<\/a><\/li>\n      <li><a href=\"#appendix\">Appendix<\/a><\/li>\n    <\/ol>\n  <\/nav>\n\n  <main>\n\n    <input type=\"checkbox\" id=\"hero-zoom\" class=\"zoom-toggle\">\n    <label for=\"hero-zoom\" class=\"zoom-backdrop\" aria-hidden=\"true\"><\/label>\n    <figure class=\"zoomable\">\n      <label for=\"hero-zoom\" class=\"zoom-trigger\" title=\"Click to enlarge\">\n      <svg viewBox=\"0 0 920 470\" role=\"img\" aria-label=\"Two separate paths reach the server: public browser traffic is port-forwarded through the router to Nginx Proxy Manager, which alone decides where it goes; Tailscale devices connect directly over an encrypted private link straight to SSH and the admin panels, never touching the router.\">\n        <defs>\n          <marker id=\"arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\">\n            <path d=\"M0,0 L10,5 L0,10 z\" fill=\"currentColor\"><\/path>\n          <\/marker>\n          <marker id=\"arrowAccent\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\">\n            <path d=\"M0,0 L10,5 L0,10 z\" fill=\"var(--accent)\"><\/path>\n          <\/marker>\n        <\/defs>\n        <g fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\">\n          <!-- server boundary -->\n          <rect x=\"470\" y=\"16\" width=\"430\" height=\"430\" rx=\"10\" stroke-dasharray=\"3 4\" opacity=\"0.6\"><\/rect>\n        <\/g>\n        <text x=\"484\" y=\"38\" font-size=\"12\" fill=\"currentColor\" opacity=\"0.75\" font-family=\"ui-monospace,monospace\">ubuntu-server \u00b7 100.111.255.36<\/text>\n\n        <!-- Internet -->\n        <g>\n          <rect x=\"16\" y=\"34\" width=\"150\" height=\"50\" rx=\"8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><\/rect>\n          <text x=\"91\" y=\"64\" text-anchor=\"middle\" font-size=\"13\" fill=\"currentColor\">Internet<\/text>\n        <\/g>\n\n        <!-- Router -->\n        <g>\n          <rect x=\"236\" y=\"34\" width=\"170\" height=\"50\" rx=\"8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><\/rect>\n          <text x=\"321\" y=\"58\" text-anchor=\"middle\" font-size=\"13\" fill=\"currentColor\">Router<\/text>\n          <text x=\"321\" y=\"74\" text-anchor=\"middle\" font-size=\"11\" fill=\"currentColor\" opacity=\"0.7\">forwards 80\/443 only<\/text>\n        <\/g>\n\n        <!-- Internet -> Router -->\n        <line x1=\"166\" y1=\"59\" x2=\"230\" y2=\"59\" stroke=\"currentColor\" stroke-width=\"1.4\" marker-end=\"url(#arrow)\"><\/line>\n        <text x=\"198\" y=\"49\" text-anchor=\"middle\" font-size=\"10.5\" fill=\"currentColor\" opacity=\"0.8\">80 \/ 443<\/text>\n\n        <!-- NPM -->\n        <g>\n          <rect x=\"560\" y=\"80\" width=\"180\" height=\"52\" rx=\"8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\"><\/rect>\n          <text x=\"650\" y=\"102\" text-anchor=\"middle\" font-size=\"13\" fill=\"currentColor\">Nginx Proxy Mgr<\/text>\n          <text x=\"650\" y=\"118\" text-anchor=\"middle\" font-size=\"11\" fill=\"currentColor\" opacity=\"0.7\">:80 \/ :443, TLS termination<\/text>\n        <\/g>\n\n        <!-- Router -> NPM -->\n        <line x1=\"406\" y1=\"59\" x2=\"560\" y2=\"100\" stroke=\"currentColor\" stroke-width=\"1.4\" marker-end=\"url(#arrow)\"><\/line>\n        <text x=\"470\" y=\"75\" text-anchor=\"middle\" font-size=\"10.5\" fill=\"currentColor\" opacity=\"0.8\">forwarded<\/text>\n\n        <!-- Nextcloud Apache -->\n        <g>\n          <rect x=\"500\" y=\"196\" width=\"185\" height=\"52\" rx=\"8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><\/rect>\n          <text x=\"592\" y=\"218\" text-anchor=\"middle\" font-size=\"12.5\" fill=\"currentColor\">Nextcloud Apache<\/text>\n          <text x=\"592\" y=\"234\" text-anchor=\"middle\" font-size=\"10.5\" fill=\"currentColor\" opacity=\"0.7\">127.0.0.1:1100<\/text>\n        <\/g>\n\n        <!-- WordPress -->\n        <g>\n          <rect x=\"705\" y=\"196\" width=\"175\" height=\"52\" rx=\"8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><\/rect>\n          <text x=\"792\" y=\"218\" text-anchor=\"middle\" font-size=\"12.5\" fill=\"currentColor\">WordPress sites<\/text>\n          <text x=\"792\" y=\"234\" text-anchor=\"middle\" font-size=\"10.5\" fill=\"currentColor\" opacity=\"0.7\">routed by path<\/text>\n        <\/g>\n\n        <!-- NPM -> Apache \/ WP -->\n        <line x1=\"620\" y1=\"132\" x2=\"592\" y2=\"196\" stroke=\"currentColor\" stroke-width=\"1.4\" marker-end=\"url(#arrow)\"><\/line>\n        <text x=\"565\" y=\"168\" text-anchor=\"middle\" font-size=\"10.5\" fill=\"currentColor\" opacity=\"0.8\">by domain<\/text>\n        <line x1=\"682\" y1=\"132\" x2=\"770\" y2=\"196\" stroke=\"currentColor\" stroke-width=\"1.4\" marker-end=\"url(#arrow)\"><\/line>\n        <text x=\"748\" y=\"168\" text-anchor=\"middle\" font-size=\"10.5\" fill=\"currentColor\" opacity=\"0.8\">by path<\/text>\n\n        <!-- Tailscale devices -->\n        <g>\n          <rect x=\"16\" y=\"360\" width=\"190\" height=\"64\" rx=\"8\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"1.6\"><\/rect>\n          <text x=\"111\" y=\"386\" text-anchor=\"middle\" font-size=\"13\" fill=\"var(--accent)\">Tailscale devices<\/text>\n          <text x=\"111\" y=\"402\" text-anchor=\"middle\" font-size=\"10.5\" fill=\"var(--accent)\" opacity=\"0.85\">laptop \u00b7 phone \u00b7 WSL \u00b7 \u2026<\/text>\n        <\/g>\n\n        <!-- Admin surface -->\n        <g>\n          <rect x=\"500\" y=\"352\" width=\"380\" height=\"72\" rx=\"8\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"1.6\"><\/rect>\n          <text x=\"690\" y=\"378\" text-anchor=\"middle\" font-size=\"12.5\" fill=\"var(--accent)\">SSH :22 \u00b7 NPM admin :81 \u00b7 AIO :8080\/8443<\/text>\n          <text x=\"690\" y=\"396\" text-anchor=\"middle\" font-size=\"10.5\" fill=\"var(--accent)\" opacity=\"0.85\">bound to the Tailscale IP only<\/text>\n        <\/g>\n\n        <!-- Tailscale -> Admin -->\n        <line x1=\"206\" y1=\"392\" x2=\"500\" y2=\"388\" stroke=\"var(--accent)\" stroke-width=\"1.6\" stroke-dasharray=\"6 5\" marker-end=\"url(#arrowAccent)\"><\/line>\n        <text x=\"350\" y=\"378\" text-anchor=\"middle\" font-size=\"10.5\" fill=\"var(--accent)\">encrypted, direct \u2014 never touches the router<\/text>\n\n        <!-- legend -->\n        <g font-family=\"ui-monospace,monospace\" font-size=\"10.5\" fill=\"currentColor\" opacity=\"0.75\">\n          <line x1=\"16\" y1=\"452\" x2=\"46\" y2=\"452\" stroke=\"currentColor\" stroke-width=\"1.4\"><\/line>\n          <text x=\"52\" y=\"456\">public path<\/text>\n          <line x1=\"150\" y1=\"452\" x2=\"180\" y2=\"452\" stroke=\"var(--accent)\" stroke-width=\"1.6\" stroke-dasharray=\"6 5\"><\/line>\n          <text x=\"186\" y=\"456\" fill=\"var(--accent)\">tailnet-only path<\/text>\n        <\/g>\n      <\/svg>\n      <\/label>\n      <p class=\"zoom-hint\">Click the diagram to enlarge \u00b7 click outside it to close<\/p>\n      <figcaption>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 \u2014 it alone decides whether a request goes to Nextcloud or a WordPress site. Every administrative surface (SSH, NPM&#8217;s own admin UI, the AIO admin panel) is bound to the Tailscale interface specifically, so it&#8217;s reachable only from devices already on the tailnet, from anywhere in the world, without ever being exposed to the router.<\/figcaption>\n    <\/figure>\n\n    <section class=\"phase\" id=\"phase-1\">\n      <div class=\"phase-head\"><span class=\"phase-num\">01<\/span><h2>Base OS &amp; access<\/h2><\/div>\n      <p class=\"dek\">Everything else assumes key-only SSH and a private admin channel exist before any service goes on the box.<\/p>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>Install Ubuntu Server<\/h3>\n      <p>Standard install (this box runs 26.04 LTS). Use the installer&#8217;s LVM-free\/plain ext4 layout on the boot disk, create the primary user during setup, and skip any bundled snap extras you don&#8217;t need.<\/p>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>If it&#8217;s a laptop: disable lid-close suspend<\/h3>\n      <p>This box is a repurposed laptop. Every default here assumes the lid stays open \u2014 closing it suspends the whole machine, taking down every service until someone physically opens it again. Override it explicitly:<\/p>\n      <pre><code>sudo mkdir -p \/etc\/systemd\/logind.conf.d<\/code><\/pre>\n      <pre><code># \/etc\/systemd\/logind.conf.d\/lid-switch.conf\n[Login]\nHandleLidSwitch=ignore\nHandleLidSwitchExternalPower=ignore\nHandleLidSwitchDocked=ignore<\/code><\/pre>\n      <pre><code>sudo systemctl restart systemd-logind<\/code><\/pre>\n      <p>A drop-in file, not an edit to <code>\/etc\/systemd\/logind.conf<\/code> directly \u2014 same convention as the SSH hardening override below, and survives package upgrades cleanly.<\/p>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>Lock SSH to key-only<\/h3>\n      <p>Add your public key to <code>~\/.ssh\/authorized_keys<\/code> during first login (cloud-init images often force password auth on until you turn it off), then override it explicitly:<\/p>\n      <pre><code># \/etc\/ssh\/sshd_config.d\/10-hardening.conf\nPasswordAuthentication no<\/code><\/pre>\n      <p><code>sudo systemctl reload sshd<\/code> after confirming key-based login works in a <em>second<\/em> terminal \u2014 don&#8217;t close the first one until the second one succeeds.<\/p>\n      <div class=\"note\">\n        <span class=\"tag\">Gotcha<\/span>\n        <div class=\"note-body\">\n          <p>Don&#8217;t assume the installer enabled the SSH service to survive a reboot \u2014 verify it explicitly:<\/p>\n          <pre><code>systemctl is-enabled ssh   # must say \"enabled\", not just \"active\"\nsudo systemctl enable ssh  # if it says disabled<\/code><\/pre>\n          <p>A service that&#8217;s merely <em>active<\/em> right now but not <em>enabled<\/em> won&#8217;t come back after a reboot \u2014 on a headless box with no other way in, that&#8217;s a real lockout, not a cosmetic gap.<\/p>\n        <\/div>\n      <\/div>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>Install Tailscale on the server<\/h3>\n      <pre><code>curl -fsSL https:\/\/tailscale.com\/install.sh | sh\nsudo tailscale up<\/code><\/pre>\n      <p>Note the Tailscale IP it&#8217;s assigned (<code>100.x.x.x<\/code>) \u2014 every later step that binds an admin port does so against this address specifically, never <code>0.0.0.0<\/code>.<\/p>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>Firewall baseline<\/h3>\n      <pre><code>sudo ufw default deny incoming\nsudo ufw default allow outgoing\nsudo ufw allow in on tailscale0 comment 'Trust all Tailscale traffic'\nsudo ufw allow 41641\/udp comment 'Tailscale direct connections'\nsudo ufw allow from 192.168.100.0\/24 to any port 22 comment 'LAN SSH fallback'\nsudo ufw enable<\/code><\/pre>\n      <div class=\"note\">\n        <span class=\"tag\">Why<\/span>\n        <p>The LAN-subnet SSH rule is a deliberate fallback for the day Tailscale itself is unreachable (router reboot mid-update, etc.) \u2014 not a general-purpose hole. It&#8217;s scoped to the local subnet, not the internet.<\/p>\n      <\/div>\n      <p>Ports 80\/443 get opened in Phase 4, once there&#8217;s a reverse proxy actually listening on them.<\/p>\n    <\/section>\n\n    <section class=\"phase\" id=\"phase-2\">\n      <div class=\"phase-head\"><span class=\"phase-num\">02<\/span><h2>Storage<\/h2><\/div>\n      <p class=\"dek\">Application data lives on the external drive; the boot NVMe only ever holds the OS and Docker.<\/p>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>Mount the external drive<\/h3>\n      <p>Find its UUID with <code>sudo blkid<\/code>, then add a stable <code>fstab<\/code> entry:<\/p>\n      <pre><code># \/etc\/fstab\nUUID=&lt;drive-uuid&gt; \/mnt\/nextcloud_data ext4 defaults,noatime,nofail 0 2<\/code><\/pre>\n      <p><code>nofail<\/code> matters \u2014 without it, a missing external drive at boot can hang the whole system waiting on the mount.<\/p>\n      <pre><code>sudo mkdir -p \/mnt\/nextcloud_data\nsudo mount -a<\/code><\/pre>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>Group convention for shared write access<\/h3>\n      <p>The Nextcloud container&#8217;s process runs as <code>www-data<\/code> inside the container, which maps to a real <code>www-data<\/code> 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:<\/p>\n      <pre><code>sudo usermod -aG www-data $USER\n# log out and back in (or open a fresh SSH session) for it to take effect<\/code><\/pre>\n      <div class=\"note\">\n        <span class=\"tag\">Gotcha<\/span>\n        <p>Group membership is read at login. A shell session opened <em>before<\/em> this command won&#8217;t see the new group \u2014 reconnect rather than trying to refresh it in place.<\/p>\n      <\/div>\n    <\/section>\n\n    <section class=\"phase\" id=\"phase-3\">\n      <div class=\"phase-head\"><span class=\"phase-num\">03<\/span><h2>Docker &amp; Nextcloud AIO<\/h2><\/div>\n      <p class=\"dek\">One container manages the rest of the Nextcloud stack for you \u2014 it just needs to know where the data lives and which ports are safe to expose.<\/p>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>Install Docker Engine<\/h3>\n      <pre><code>curl -fsSL https:\/\/get.docker.com | sh\nsudo usermod -aG docker $USER\n# reconnect for the group to apply<\/code><\/pre>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>Run the AIO mastercontainer<\/h3>\n      <p>This is the one container Nextcloud AIO needs directly \u2014 it manages every other Nextcloud container itself via the Docker socket. The two choices that matter: <code>NEXTCLOUD_DATADIR<\/code> points at the external drive, and the web-admin ports (<code>8080<\/code>\/<code>8443<\/code>) bind to the Tailscale IP only, never the public interface.<\/p>\n      <pre><code>sudo docker run \\\n  --sig-proxy=false \\\n  --name nextcloud-aio-mastercontainer \\\n  --restart always \\\n  --publish &lt;tailscale-ip&gt;:8080:8080 \\\n  --publish &lt;tailscale-ip&gt;:8443:8443 \\\n  --env APACHE_PORT=1100 \\\n  --env NEXTCLOUD_DATADIR=\/mnt\/nextcloud_data \\\n  --volume nextcloud_aio_mastercontainer:\/mnt\/docker-aio-config \\\n  --volume \/var\/run\/docker.sock:\/var\/run\/docker.sock:ro \\\n  nextcloud\/all-in-one:latest<\/code><\/pre>\n      <p><code>APACHE_PORT=1100<\/code> matters: it moves the internal Apache container off port 80, freeing that port for the reverse proxy set up in the next phase \u2014 Apache stays bound to <code>127.0.0.1<\/code> only and is never reached directly.<\/p>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>Complete setup over Tailscale<\/h3>\n      <p>From any device on the tailnet, open <code>https:\/\/&lt;tailscale-ip&gt;:8443<\/code> and follow AIO&#8217;s own wizard (it issues itself a self-signed cert for this step \u2014 that warning is expected). It will pull and start the rest of the stack: the Nextcloud app container, Postgres, Redis, image processing, Talk, etc.<\/p>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>Install Portainer (Docker management UI)<\/h3>\n      <p>Same private-admin pattern as everything else \u2014 bound to the Tailscale IP only, never public:<\/p>\n      <pre><code># ~\/docker\/portainer\/docker-compose.yml\nservices:\n  portainer:\n    image: portainer\/portainer-ce:latest\n    container_name: portainer\n    restart: always\n    ports:\n      - \"&lt;tailscale-ip&gt;:9443:9443\"\n    volumes:\n      - \/var\/run\/docker.sock:\/var\/run\/docker.sock\n      - .\/data:\/data<\/code><\/pre>\n      <pre><code>cd ~\/docker\/portainer &amp;&amp; docker compose up -d<\/code><\/pre>\n      <div class=\"note\">\n        <span class=\"tag\">Gotcha<\/span>\n        <div class=\"note-body\">\n          <p>Portainer locks its own setup screen <strong>5 minutes<\/strong> after first start if no admin account has been created yet (&#8220;the instance timed out for security purposes&#8221;) \u2014 if that happens, <code>docker restart portainer<\/code> resets the timer.<\/p>\n          <p>Recent Portainer versions also require a one-time <strong>setup token<\/strong> pasted into the initial admin-creation screen, printed only in the container&#8217;s startup logs:<\/p>\n          <pre><code>docker logs portainer 2&gt;&amp;1 | grep setup_token<\/code><\/pre>\n          <p>Grab it and create the admin account promptly \u2014 both the token and the 5-minute window are freshly (re)issued on every restart.<\/p>\n        <\/div>\n      <\/div>\n    <\/section>\n\n    <section class=\"phase\" id=\"phase-4\">\n      <div class=\"phase-head\"><span class=\"phase-num\">04<\/span><h2>Reverse proxy &amp; public access<\/h2><\/div>\n      <p class=\"dek\">One entry point for the whole internet-facing surface: Nginx Proxy Manager terminates TLS and decides where every request actually goes.<\/p>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>Run Nginx Proxy Manager<\/h3>\n      <p>On the same Docker network as the Nextcloud containers, so it can reach them by container name:<\/p>\n      <pre><code># ~\/docker\/npm\/docker-compose.yml\nservices:\n  npm:\n    image: 'jc21\/nginx-proxy-manager:latest'\n    container_name: npm\n    restart: always\n    ports:\n      - '80:80'\n      - '443:443'\n      - '&lt;tailscale-ip&gt;:81:81'\n    volumes:\n      - .\/data:\/data\n      - .\/letsencrypt:\/etc\/letsencrypt\n    networks:\n      - nextcloud-aio\n\nnetworks:\n  nextcloud-aio:\n    external: true<\/code><\/pre>\n      <pre><code>cd ~\/docker\/npm &amp;&amp; docker compose up -d<\/code><\/pre>\n      <p>Its own admin UI (port <code>81<\/code>) is bound to the Tailscale IP the same way AIO&#8217;s is \u2014 the reverse proxy that fronts the public internet is itself only administrable privately.<\/p>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>Dynamic DNS<\/h3>\n      <p>A free DuckDNS hostname, kept current by a systemd timer rather than cron (survives reboots cleanly, logs to journald):<\/p>\n      <pre><code># ~\/duckdns\/duck.sh\necho url=\"https:\/\/www.duckdns.org\/update?domains=&lt;yourname&gt;&amp;token=&lt;your-token&gt;&amp;ip=\" \\\n  | curl -o ~\/duckdns\/duck.log -K -<\/code><\/pre>\n      <pre><code># \/etc\/systemd\/system\/duckdns.service\n[Unit]\nDescription=Update DuckDNS IP address\nWants=network-online.target\nAfter=network-online.target\n\n[Service]\nType=oneshot\nUser=&lt;you&gt;\nExecStart=\/home\/&lt;you&gt;\/duckdns\/duck.sh\n\n# \/etc\/systemd\/system\/duckdns.timer\n[Unit]\nDescription=Run DuckDNS update every 5 minutes\n\n[Timer]\nOnBootSec=1min\nOnUnitActiveSec=5min\nPersistent=true\n\n[Install]\nWantedBy=timers.target<\/code><\/pre>\n      <pre><code>chmod 700 ~\/duckdns\/duck.sh\nsudo systemctl enable --now duckdns.timer<\/code><\/pre>\n      <p>One DuckDNS account can hold several hostnames under the same token \u2014 register one now per service you plan to expose (e.g. one for Nextcloud, one as a hub for everything else).<\/p>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>Router port forward<\/h3>\n      <p>Forward <strong>80 and 443 only<\/strong>, to the server&#8217;s LAN IP. Nothing else \u2014 SSH stays off the router entirely, reachable only via Tailscale (and the LAN fallback from Phase 1).<\/p>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>Proxy host + certificate<\/h3>\n      <p>In NPM&#8217;s admin UI: <strong>Proxy Hosts \u2192 Add Proxy Host<\/strong> \u2014 domain name, forward to the Nextcloud Apache container on port <code>1100<\/code>, then on the SSL tab request a new Let&#8217;s Encrypt certificate and force SSL. NPM handles renewal automatically from there.<\/p>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>Open the firewall for real traffic<\/h3>\n      <pre><code>sudo ufw allow 80\/tcp\nsudo ufw allow 443\/tcp<\/code><\/pre>\n      <p>The full, final rule set looks like this:<\/p>\n      <table>\n        <tbody><tr><th>Rule<\/th><th>Purpose<\/th><\/tr>\n        <tr><td><code>tailscale0<\/code> \u2192 allow all<\/td><td>Every private admin surface<\/td><\/tr>\n        <tr><td><code>41641\/udp<\/code> \u2192 allow<\/td><td>Tailscale direct (NAT-traversed) connections<\/td><\/tr>\n        <tr><td><code>22\/tcp<\/code> from LAN subnet \u2192 allow<\/td><td>SSH fallback if Tailscale is down<\/td><\/tr>\n        <tr><td><code>80\/tcp<\/code>, <code>443\/tcp<\/code> \u2192 allow<\/td><td>Public HTTP\/HTTPS, handled entirely by NPM<\/td><\/tr>\n        <tr><td>everything else \u2192 deny<\/td><td>Default posture<\/td><\/tr>\n      <\/tbody><\/table>\n    <\/section>\n\n    <section class=\"phase\" id=\"phase-5\">\n      <div class=\"phase-head\"><span class=\"phase-num\">05<\/span><h2>Adding client devices<\/h2><\/div>\n      <p class=\"dek\">The same two-step pattern for every new laptop, phone, or WSL environment that needs to administer the box.<\/p>\n      <ol>\n        <li>Install Tailscale on the new device, sign into the same tailnet account.<\/li>\n        <li>Generate a device-specific ed25519 key (<code>ssh-keygen -t ed25519 -C \"device-name\"<\/code>) \u2014 never reuse one key across devices.<\/li>\n        <li>Get the new public key onto the server via a device\/session that <em>already<\/em> has access, appended to <code>~\/.ssh\/authorized_keys<\/code>.<\/li>\n      <\/ol>\n      <div class=\"note\">\n        <span class=\"tag\">Note<\/span>\n        <p>A brand-new device can never authorize itself \u2014 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.<\/p>\n      <\/div>\n    <\/section>\n\n    <section class=\"phase\" id=\"phase-6\">\n      <div class=\"phase-head\"><span class=\"phase-num\">06<\/span><h2>Bulk media import<\/h2><\/div>\n      <p class=\"dek\">The repeatable recipe for getting a large personal archive onto the Nextcloud data drive without going through the (much slower) WebDAV\/web upload path.<\/p>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>Open up write access first<\/h3>\n      <p>Nextcloud&#8217;s own files are owned by <code>www-data<\/code> with the setgid bit set, so new files created underneath inherit the right group \u2014 but pre-existing top-level folders may not have group-write set. Fix the destination folder before copying into it:<\/p>\n      <pre><code>sudo chmod g+w \"\/mnt\/nextcloud_data\/admin\/files\/&lt;target folder&gt;\"<\/code><\/pre>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>Copy in with rsync, not the web UI<\/h3>\n      <p>Run from whichever machine actually holds the source files, over SSH to the server:<\/p>\n      <pre><code>rsync -a --no-owner --no-group --no-perms --omit-dir-times \\\n  --partial --info=progress2 \\\n  --exclude 'Thumbs.db' --exclude 'desktop.ini' \\\n  --exclude 'System Volume Information' --exclude '$RECYCLE.BIN' \\\n  -e ssh \\\n  \"\/path\/to\/source\/\" \\\n  \"user@&lt;tailscale-ip&gt;:\/mnt\/nextcloud_data\/admin\/files\/&lt;target folder&gt;\/\"<\/code><\/pre>\n      <div class=\"note\">\n        <span class=\"tag\">Why these flags<\/span>\n        <p>A non-root SSH user can&#8217;t <code>chown<\/code>\/<code>chgrp<\/code>\/set arbitrary timestamps on files it doesn&#8217;t own \u2014 and some destination folders are pre-existing and owned by <code>www-data<\/code>, 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.<\/p>\n      <\/div>\n      <p>It&#8217;s safe to re-run the exact same command if a transfer is interrupted \u2014 already-copied files are skipped on the size\/mtime check, so only the gap gets retried.<\/p>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>Two filesystem limits worth knowing before they surprise you<\/h3>\n      <div class=\"note\">\n        <span class=\"tag\">Gotcha<\/span>\n        <div class=\"note-body\">\n          <p><strong>Unicode normalization.<\/strong> Some sources (old phone exports especially) produce filenames using decomposed Unicode (NFD) \u2014 accented\/diacritic characters stored as separate combining marks. Nextcloud&#8217;s scanner silently refuses to register these (&#8220;incompatible encoding&#8221;). Fix in bulk with a short walk that renames anything not already in NFC form:<\/p>\n          <pre><code>python3 -c \"\nimport os, unicodedata\nfor dirpath, dirnames, filenames in os.walk('&lt;path&gt;', topdown=False):\n    for name in filenames + dirnames:\n        nfc = unicodedata.normalize('NFC', name)\n        if nfc != name:\n            os.rename(os.path.join(dirpath, name), os.path.join(dirpath, nfc))\n\"<\/code><\/pre>\n          <p><strong>Filename length.<\/strong> ext4 caps individual filenames at 255 <em>bytes<\/em>, not characters \u2014 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&#8217;s no way around the filesystem limit.<\/p>\n        <\/div>\n      <\/div>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>Fix ownership, then register with Nextcloud<\/h3>\n      <pre><code>sudo chown -R www-data:www-data \"\/mnt\/nextcloud_data\/admin\/files\/&lt;target folder&gt;\"\ndocker exec --user www-data nextcloud-aio-nextcloud php occ files:scan \\\n  --path=\"admin\/files\/&lt;target folder&gt;\"<\/code><\/pre>\n      <p>Nextcloud never watches the filesystem for out-of-band changes \u2014 anything copied in directly is invisible until this scan runs. The scan report&#8217;s <code>Errors<\/code> column should read <code>0<\/code>; anything else is almost always one of the two gotchas above.<\/p>\n    <\/section>\n\n    <section class=\"phase\" id=\"phase-7\">\n      <div class=\"phase-head\"><span class=\"phase-num\">07<\/span><h2>WordPress hosting<\/h2><\/div>\n      <p class=\"dek\">A whole family of independent WordPress installs, each with its own database, reachable at their own path under one domain and one certificate \u2014 a landing page with cards, not a folder of subdomains.<\/p>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>The shape of it<\/h3>\n      <p><code>ahaddad-wp.duckdns.org\/<\/code> is a static cards page (its own tiny <code>nginx:alpine<\/code> container, no database). <code>\/my<\/code> is another static cards page, one level down. <code>\/my\/mylogbook<\/code> and <code>\/my\/mylearning<\/code> are full, independent WordPress installs \u2014 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.<\/p>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>A WordPress install that knows it lives in a subpath<\/h3>\n      <p>Two things make a normal WordPress container work correctly under <code>\/my\/&lt;slug&gt;<\/code> 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.<\/p>\n      <pre><code># apache-subpath.conf\nAlias \/my\/&lt;slug&gt; \/var\/www\/html\n&lt;Directory \/var\/www\/html&gt;\n    AllowOverride All\n    Require all granted\n&lt;\/Directory&gt;<\/code><\/pre>\n      <pre><code># docker-compose.yml\nservices:\n  wordpress:\n    image: wordpress:latest\n    container_name: wordpress-&lt;slug&gt;\n    restart: always\n    environment:\n      WORDPRESS_DB_HOST: wordpress-&lt;slug&gt;-db\n      WORDPRESS_DB_NAME: wordpress\n      WORDPRESS_DB_USER: wordpress\n      WORDPRESS_DB_PASSWORD: ${WORDPRESS_DB_PASSWORD}\n      WORDPRESS_CONFIG_EXTRA: |\n        define('WP_HOME','https:\/\/ahaddad-wp.duckdns.org\/my\/&lt;slug&gt;');\n        define('WP_SITEURL','https:\/\/ahaddad-wp.duckdns.org\/my\/&lt;slug&gt;');\n    volumes:\n      - .\/wp-content:\/var\/www\/html\/wp-content\n      - .\/apache-subpath.conf:\/etc\/apache2\/conf-enabled\/subpath.conf:ro\n    networks: [internal, nextcloud-aio]\n    depends_on: [db]\n\n  db:\n    image: mariadb:11\n    container_name: wordpress-&lt;slug&gt;-db\n    restart: always\n    environment:\n      MYSQL_DATABASE: wordpress\n      MYSQL_USER: wordpress\n      MYSQL_PASSWORD: ${WORDPRESS_DB_PASSWORD}\n      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}\n    volumes: [.\/db-data:\/var\/lib\/mysql]\n    networks: [internal]\n\nnetworks:\n  internal:\n  nextcloud-aio:\n    external: true<\/code><\/pre>\n      <p>Generate the two passwords into <code>.env<\/code> before starting it \u2014 never hard-code them into the compose file itself:<\/p>\n      <pre><code>openssl rand -base64 24 | tr -d '\/+=' | head -c 32   # run twice, once per secret<\/code><\/pre>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>Route it in NPM<\/h3>\n      <p>Every site is one <strong>Custom Location<\/strong> on the single existing proxy host \u2014 not a new proxy host each time. <code>Proxy Hosts \u2192 ahaddad-wp.duckdns.org \u2192 Edit \u2192 Custom Locations \u2192 Add location<\/code>: path <code>\/my\/&lt;slug&gt;<\/code>, forward to <code>wordpress-&lt;slug&gt;<\/code> on port <code>80<\/code>.<\/p>\n      <div class=\"note\">\n        <span class=\"tag\">Gotcha<\/span>\n        <p>Nginx resolves upstream hostnames <em>at save time<\/em>, not lazily \u2014 if the target container isn&#8217;t already up and running on the shared network, saving fails with a bare &#8220;Internal error&#8221; and no useful detail in the UI. Always <code>docker compose up -d<\/code> the new site first, confirm it&#8217;s reachable (<code>docker exec npm curl -s -o \/dev\/null -w '%{http_code}' http:\/\/wordpress-&lt;slug&gt;:80\/my\/&lt;slug&gt;\/<\/code>), <em>then<\/em> add the NPM location.<\/p>\n      <\/div>\n      <p>If a save ever does stick in that broken state, it&#8217;s fixable directly \u2014 NPM stores each proxy host&#8217;s custom locations as a JSON column, and the on-disk nginx config is just a generated file:<\/p>\n      <pre><code>sudo sqlite3 ~\/docker\/npm\/data\/database.sqlite \\\n  \"SELECT locations FROM proxy_host WHERE id=&lt;id&gt;;\"\n# edit the JSON, then write it back:\nsudo sqlite3 ~\/docker\/npm\/data\/database.sqlite \\\n  \"UPDATE proxy_host SET locations='&lt;corrected json&gt;' WHERE id=&lt;id&gt;;\"\ndocker exec npm nginx -t        # must say \"test is successful\"\ndocker exec npm nginx -s reload<\/code><\/pre>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>Static sites, the lighter version<\/h3>\n      <p>No database, no Apache alias trick \u2014 just a bind-mounted folder behind nginx:<\/p>\n      <pre><code>services:\n  site:\n    image: nginx:alpine\n    container_name: wp-&lt;slug&gt;\n    restart: always\n    volumes: [.\/html:\/usr\/share\/nginx\/html:ro]\n    networks: [nextcloud-aio]\n\nnetworks:\n  nextcloud-aio:\n    external: true<\/code><\/pre>\n      <p>If it&#8217;s built by a static-site generator, set its base\/public-path build option to <code>\/my\/&lt;slug&gt;<\/code> so its own internal links resolve correctly \u2014 the build-time equivalent of <code>WP_HOME<\/code>.<\/p>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>The landing pages<\/h3>\n      <p>Plain static HTML, one card per site, no build step \u2014 edit and the change is live on next request:<\/p>\n      <pre><code>&lt;a class=\"card\" href=\"\/my\/&lt;slug&gt;\"&gt;\n  &lt;h2&gt;Display name&lt;\/h2&gt;\n  &lt;p&gt;Short description&lt;\/p&gt;\n&lt;\/a&gt;<\/code><\/pre>\n    <\/section>\n\n    <section class=\"phase\" id=\"system-map\">\n      <div class=\"phase-head\"><span class=\"phase-num\">08<\/span><h2>Full system map<\/h2><\/div>\n      <p class=\"dek\">The same picture as the top, expanded to show every layer that&#8217;s actually running \u2014 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.<\/p>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>Layout<\/h3>\n      <input type=\"checkbox\" id=\"layout-zoom\" class=\"zoom-toggle\">\n      <label for=\"layout-zoom\" class=\"zoom-backdrop\" aria-hidden=\"true\"><\/label>\n      <figure class=\"zoomable\">\n        <label for=\"layout-zoom\" class=\"zoom-trigger\" title=\"Click to enlarge\">\n        <svg viewBox=\"0 0 1040 520\" role=\"img\" aria-label=\"DuckDNS resolves the domain to the router's public IP. The router forwards 80 and 443 to Nginx Proxy Manager, which routes by domain to the Nextcloud AIO stack and by path to the WordPress stack, both running inside Docker Engine alongside Portainer, which manages the whole Docker layer via the Docker socket. Tailscale devices connect directly to every lock-marked admin port and to SSH, entirely bypassing the router.\">\n          <defs>\n            <marker id=\"arrow2\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\">\n              <path d=\"M0,0 L10,5 L0,10 z\" fill=\"currentColor\"><\/path>\n            <\/marker>\n            <marker id=\"arrowAccent2\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\">\n              <path d=\"M0,0 L10,5 L0,10 z\" fill=\"var(--accent)\"><\/path>\n            <\/marker>\n          <\/defs>\n\n          <!-- server boundary -->\n          <rect x=\"500\" y=\"16\" width=\"520\" height=\"480\" rx=\"10\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-dasharray=\"3 4\" opacity=\"0.6\"><\/rect>\n          <text x=\"514\" y=\"38\" font-size=\"12\" fill=\"currentColor\" opacity=\"0.75\" font-family=\"ui-monospace,monospace\">ubuntu-server<\/text>\n\n          <!-- docker engine boundary -->\n          <rect x=\"520\" y=\"60\" width=\"480\" height=\"340\" rx=\"10\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-dasharray=\"2 4\" opacity=\"0.55\"><\/rect>\n          <text x=\"534\" y=\"80\" font-size=\"11.5\" fill=\"currentColor\" opacity=\"0.7\" font-family=\"ui-monospace,monospace\">Docker Engine<\/text>\n\n          <!-- Internet -->\n          <rect x=\"20\" y=\"40\" width=\"170\" height=\"50\" rx=\"8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><\/rect>\n          <text x=\"105\" y=\"70\" text-anchor=\"middle\" font-size=\"13\" fill=\"currentColor\">Internet<\/text>\n\n          <!-- Router -->\n          <rect x=\"250\" y=\"40\" width=\"170\" height=\"56\" rx=\"8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><\/rect>\n          <text x=\"335\" y=\"63\" text-anchor=\"middle\" font-size=\"13\" fill=\"currentColor\">Router<\/text>\n          <text x=\"335\" y=\"80\" text-anchor=\"middle\" font-size=\"10.5\" fill=\"currentColor\" opacity=\"0.7\">forwards 80\/443 only<\/text>\n\n          <!-- Internet -> Router -->\n          <line x1=\"190\" y1=\"65\" x2=\"244\" y2=\"65\" stroke=\"currentColor\" stroke-width=\"1.4\" marker-end=\"url(#arrow2)\"><\/line>\n          <text x=\"217\" y=\"55\" text-anchor=\"middle\" font-size=\"10\" fill=\"currentColor\" opacity=\"0.8\">80\/443<\/text>\n\n          <!-- DuckDNS -->\n          <rect x=\"250\" y=\"152\" width=\"170\" height=\"56\" rx=\"8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><\/rect>\n          <text x=\"335\" y=\"175\" text-anchor=\"middle\" font-size=\"13\" fill=\"currentColor\">DuckDNS<\/text>\n          <text x=\"335\" y=\"192\" text-anchor=\"middle\" font-size=\"10.5\" fill=\"currentColor\" opacity=\"0.7\">ahaddad \u00b7 ahaddad-wp<\/text>\n\n          <!-- DuckDNS -> Router -->\n          <line x1=\"335\" y1=\"152\" x2=\"335\" y2=\"100\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-dasharray=\"5 4\" marker-end=\"url(#arrow2)\"><\/line>\n          <text x=\"422\" y=\"128\" font-size=\"10\" fill=\"currentColor\" opacity=\"0.75\">resolves to<\/text>\n\n          <!-- NPM -->\n          <rect x=\"540\" y=\"96\" width=\"200\" height=\"60\" rx=\"8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\"><\/rect>\n          <text x=\"640\" y=\"118\" text-anchor=\"middle\" font-size=\"12.5\" fill=\"currentColor\">Nginx Proxy Mgr<\/text>\n          <text x=\"640\" y=\"134\" text-anchor=\"middle\" font-size=\"10\" fill=\"currentColor\" opacity=\"0.7\">:80 \/ :443 public<\/text>\n          <text x=\"640\" y=\"148\" text-anchor=\"middle\" font-size=\"9.5\" fill=\"var(--accent)\">\ud83d\udd12 :81 admin<\/text>\n\n          <!-- Router -> NPM -->\n          <line x1=\"420\" y1=\"65\" x2=\"540\" y2=\"118\" stroke=\"currentColor\" stroke-width=\"1.4\" marker-end=\"url(#arrow2)\"><\/line>\n          <text x=\"478\" y=\"82\" text-anchor=\"middle\" font-size=\"10\" fill=\"currentColor\" opacity=\"0.8\">forwarded<\/text>\n\n          <!-- Portainer -->\n          <rect x=\"760\" y=\"96\" width=\"200\" height=\"60\" rx=\"8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\"><\/rect>\n          <text x=\"860\" y=\"118\" text-anchor=\"middle\" font-size=\"12.5\" fill=\"currentColor\">Portainer<\/text>\n          <text x=\"860\" y=\"134\" text-anchor=\"middle\" font-size=\"10\" fill=\"currentColor\" opacity=\"0.7\">manages this layer<\/text>\n          <text x=\"860\" y=\"148\" text-anchor=\"middle\" font-size=\"9.5\" fill=\"var(--accent)\">\ud83d\udd12 :9443<\/text>\n\n          <!-- Portainer -> docker boundary label -->\n          <line x1=\"800\" y1=\"96\" x2=\"700\" y2=\"80\" stroke=\"currentColor\" stroke-width=\"1.2\" stroke-dasharray=\"4 4\" marker-end=\"url(#arrow2)\"><\/line>\n          <text x=\"770\" y=\"72\" font-size=\"9.5\" fill=\"currentColor\" opacity=\"0.7\">docker.sock<\/text>\n\n          <!-- Nextcloud AIO stack -->\n          <rect x=\"540\" y=\"196\" width=\"200\" height=\"110\" rx=\"8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><\/rect>\n          <text x=\"640\" y=\"218\" text-anchor=\"middle\" font-size=\"12.5\" fill=\"currentColor\">Nextcloud AIO<\/text>\n          <text x=\"640\" y=\"235\" text-anchor=\"middle\" font-size=\"10\" fill=\"currentColor\" opacity=\"0.7\">10 containers<\/text>\n          <text x=\"640\" y=\"250\" text-anchor=\"middle\" font-size=\"10\" fill=\"currentColor\" opacity=\"0.7\">external-drive datadir<\/text>\n          <text x=\"640\" y=\"270\" text-anchor=\"middle\" font-size=\"9.5\" fill=\"var(--accent)\">\ud83d\udd12 :8080 \/ :8443<\/text>\n          <text x=\"640\" y=\"286\" text-anchor=\"middle\" font-size=\"9.5\" fill=\"currentColor\" opacity=\"0.6\">Apache 127.0.0.1:1100<\/text>\n\n          <!-- NPM -> AIO -->\n          <line x1=\"610\" y1=\"156\" x2=\"610\" y2=\"196\" stroke=\"currentColor\" stroke-width=\"1.4\" marker-end=\"url(#arrow2)\"><\/line>\n          <text x=\"580\" y=\"176\" text-anchor=\"middle\" font-size=\"10\" fill=\"currentColor\" opacity=\"0.8\">by domain<\/text>\n\n          <!-- WordPress stack -->\n          <rect x=\"760\" y=\"196\" width=\"200\" height=\"110\" rx=\"8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><\/rect>\n          <text x=\"860\" y=\"218\" text-anchor=\"middle\" font-size=\"12.5\" fill=\"currentColor\">WordPress<\/text>\n          <text x=\"860\" y=\"235\" text-anchor=\"middle\" font-size=\"10\" fill=\"currentColor\" opacity=\"0.7\">landing + 2 sites<\/text>\n          <text x=\"860\" y=\"250\" text-anchor=\"middle\" font-size=\"10\" fill=\"currentColor\" opacity=\"0.7\">each own MariaDB<\/text>\n          <text x=\"860\" y=\"270\" text-anchor=\"middle\" font-size=\"9.5\" fill=\"currentColor\" opacity=\"0.6\">no public admin port<\/text>\n\n          <!-- NPM -> WP -->\n          <line x1=\"680\" y1=\"156\" x2=\"850\" y2=\"196\" stroke=\"currentColor\" stroke-width=\"1.4\" marker-end=\"url(#arrow2)\"><\/line>\n          <text x=\"790\" y=\"176\" text-anchor=\"middle\" font-size=\"10\" fill=\"currentColor\" opacity=\"0.8\">by path<\/text>\n\n          <!-- SSH -->\n          <rect x=\"540\" y=\"424\" width=\"420\" height=\"56\" rx=\"8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"><\/rect>\n          <text x=\"750\" y=\"447\" text-anchor=\"middle\" font-size=\"12.5\" fill=\"currentColor\">SSH :22 \u2014 OS-level, not containerized<\/text>\n          <text x=\"750\" y=\"464\" text-anchor=\"middle\" font-size=\"9.5\" fill=\"var(--accent)\">\ud83d\udd12 tailscale + LAN fallback only<\/text>\n\n          <!-- Tailscale devices -->\n          <rect x=\"20\" y=\"416\" width=\"210\" height=\"64\" rx=\"8\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"1.6\"><\/rect>\n          <text x=\"125\" y=\"442\" text-anchor=\"middle\" font-size=\"13\" fill=\"var(--accent)\">Tailscale devices<\/text>\n          <text x=\"125\" y=\"458\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--accent)\" opacity=\"0.85\">laptop \u00b7 phone \u00b7 WSL \u00b7 \u2026<\/text>\n\n          <!-- Tailscale -> server boundary -->\n          <line x1=\"230\" y1=\"450\" x2=\"500\" y2=\"452\" stroke=\"var(--accent)\" stroke-width=\"1.6\" stroke-dasharray=\"6 5\" marker-end=\"url(#arrowAccent2)\"><\/line>\n          <text x=\"365\" y=\"440\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--accent)\">direct to every \ud83d\udd12 port<\/text>\n\n          <!-- legend -->\n          <g font-family=\"ui-monospace,monospace\" font-size=\"10\" fill=\"currentColor\" opacity=\"0.75\">\n            <line x1=\"20\" y1=\"500\" x2=\"50\" y2=\"500\" stroke=\"currentColor\" stroke-width=\"1.4\"><\/line>\n            <text x=\"56\" y=\"504\">public path<\/text>\n            <line x1=\"150\" y1=\"500\" x2=\"180\" y2=\"500\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-dasharray=\"5 4\"><\/line>\n            <text x=\"186\" y=\"504\">control \/ DNS<\/text>\n            <line x1=\"310\" y1=\"500\" x2=\"340\" y2=\"500\" stroke=\"var(--accent)\" stroke-width=\"1.6\" stroke-dasharray=\"6 5\"><\/line>\n            <text x=\"346\" y=\"504\" fill=\"var(--accent)\">tailnet-only<\/text>\n          <\/g>\n        <\/svg>\n        <\/label>\n        <p class=\"zoom-hint\">Click the diagram to enlarge \u00b7 click outside it to close<\/p>\n        <figcaption>Everything left of the &#8220;ubuntu-server&#8221; boundary is off-box. DuckDNS isn&#8217;t in the traffic path itself \u2014 a systemd timer on the server pushes its current public IP to DuckDNS every 5 minutes, and that&#8217;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.<\/figcaption>\n      <\/figure>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>Every box, labeled<\/h3>\n      <p>Same map, with the exact address, port, and access method behind each piece.<\/p>\n\n      <h4 class=\"group\">Edge &amp; DNS \u2014 not containers, OS\/network level<\/h4>\n      <div class=\"spec-grid\">\n        <div class=\"spec-card\">\n          <div class=\"name\">DuckDNS<\/div>\n          <dl>\n            <dt>Hosts<\/dt><dd>ahaddad.duckdns.org, ahaddad-wp.duckdns.org<\/dd>\n            <dt>Resolves to<\/dt><dd>178.153.184.130 (dynamic)<\/dd>\n            <dt>Kept current by<\/dt><dd>duckdns.timer, every 5 min<\/dd>\n            <dt>Script<\/dt><dd>~\/duckdns\/duck.sh<\/dd>\n          <\/dl>\n        <\/div>\n        <div class=\"spec-card\">\n          <div class=\"name\">Router<\/div>\n          <dl>\n            <dt>WAN<\/dt><dd>178.153.184.130 (dynamic)<\/dd>\n            <dt>Forwards<\/dt><dd>80\/tcp, 443\/tcp \u2192 192.168.100.13<\/dd>\n            <dt>Everything else<\/dt><dd>not forwarded<\/dd>\n          <\/dl>\n        <\/div>\n        <div class=\"spec-card\">\n          <div class=\"name\">SSH<span class=\"lock\">\ud83d\udd12 restricted<\/span><\/div>\n          <dl>\n            <dt>Port<\/dt><dd>22\/tcp<\/dd>\n            <dt>Auth<\/dt><dd>key-only (PasswordAuthentication no)<\/dd>\n            <dt>Allowed from<\/dt><dd>tailscale0 (anywhere) + 192.168.100.0\/24<\/dd>\n            <dt>Config<\/dt><dd>\/etc\/ssh\/sshd_config.d\/10-hardening.conf<\/dd>\n          <\/dl>\n        <\/div>\n      <\/div>\n\n      <h4 class=\"group\">Reverse proxy &amp; management<\/h4>\n      <div class=\"spec-grid\">\n        <div class=\"spec-card\">\n          <div class=\"name\">npm<span class=\"lock\">\ud83d\udd12 :81<\/span><\/div>\n          <dl>\n            <dt>Image<\/dt><dd>jc21\/nginx-proxy-manager:latest<\/dd>\n            <dt>Network<\/dt><dd>nextcloud-aio \u00b7 172.18.0.12<\/dd>\n            <dt>Public<\/dt><dd>0.0.0.0:80, 0.0.0.0:443<\/dd>\n            <dt>Admin<\/dt><dd>100.111.255.36:81<\/dd>\n            <dt>Host 1<\/dt><dd>ahaddad.duckdns.org \u2192 nextcloud-aio-apache:1100<\/dd>\n            <dt>Host 2<\/dt><dd>ahaddad-wp.duckdns.org \u2192 landing:80, + \/my\/mylogbook, \/my\/mylearning<\/dd>\n          <\/dl>\n        <\/div>\n        <div class=\"spec-card\">\n          <div class=\"name\">portainer<span class=\"lock\">\ud83d\udd12 :9443<\/span><\/div>\n          <dl>\n            <dt>Image<\/dt><dd>portainer\/portainer-ce:latest<\/dd>\n            <dt>Network<\/dt><dd>portainer_default \u00b7 172.21.0.2<\/dd>\n            <dt>Admin<\/dt><dd>100.111.255.36:9443<\/dd>\n            <dt>Mounts<\/dt><dd>\/var\/run\/docker.sock (rw) \u2014 manages every container on the host<\/dd>\n          <\/dl>\n        <\/div>\n      <\/div>\n\n      <h4 class=\"group\">Nextcloud AIO stack \u2014 network: nextcloud-aio (172.18.0.0\/16)<\/h4>\n      <div class=\"spec-grid\">\n        <div class=\"spec-card\">\n          <div class=\"name\">mastercontainer<span class=\"lock\">\ud83d\udd12 :8080\/:8443<\/span><\/div>\n          <dl>\n            <dt>IP<\/dt><dd>172.18.0.2<\/dd>\n            <dt>Admin<\/dt><dd>100.111.255.36:8080, :8443<\/dd>\n            <dt>Role<\/dt><dd>owns docker.sock (ro), manages the other 9 AIO containers<\/dd>\n          <\/dl>\n        <\/div>\n        <div class=\"spec-card\">\n          <div class=\"name\">apache<\/div>\n          <dl>\n            <dt>IP<\/dt><dd>172.18.0.11<\/dd>\n            <dt>Reached by NPM<\/dt><dd>via docker network, port 1100<\/dd>\n            <dt>Host mapping<\/dt><dd>127.0.0.1:1100 (local debug only)<\/dd>\n          <\/dl>\n        <\/div>\n        <div class=\"spec-card\">\n          <div class=\"name\">nextcloud (app)<\/div>\n          <dl>\n            <dt>IP<\/dt><dd>172.18.0.10<\/dd>\n            <dt>Port<\/dt><dd>9000, internal only<\/dd>\n          <\/dl>\n        <\/div>\n        <div class=\"spec-card\">\n          <div class=\"name\">database (postgres)<\/div>\n          <dl>\n            <dt>IP<\/dt><dd>172.18.0.7<\/dd>\n            <dt>Port<\/dt><dd>5432, internal only<\/dd>\n          <\/dl>\n        <\/div>\n        <div class=\"spec-card\">\n          <div class=\"name\">redis<\/div>\n          <dl>\n            <dt>IP<\/dt><dd>172.18.0.8<\/dd>\n            <dt>Port<\/dt><dd>6379, internal only<\/dd>\n          <\/dl>\n        <\/div>\n        <div class=\"spec-card\">\n          <div class=\"name\">talk<\/div>\n          <dl>\n            <dt>IP<\/dt><dd>172.18.0.4<\/dd>\n            <dt>Public<\/dt><dd>0.0.0.0:3478 tcp+udp (TURN\/STUN)<\/dd>\n          <\/dl>\n        <\/div>\n        <div class=\"spec-card\">\n          <div class=\"name\">imaginary \u00b7 notify-push \u00b7 whiteboard \u00b7 eurooffice<\/div>\n          <dl>\n            <dt>IPs<\/dt><dd>172.18.0.9, .5, .6, .3<\/dd>\n            <dt>Ports<\/dt><dd>internal only, no host mapping<\/dd>\n          <\/dl>\n        <\/div>\n        <div class=\"spec-card\">\n          <div class=\"name\">data directory<\/div>\n          <dl>\n            <dt>Host path<\/dt><dd>\/mnt\/nextcloud_data<\/dd>\n            <dt>Device<\/dt><dd>external drive, ext4, fstab + nofail<\/dd>\n          <\/dl>\n        <\/div>\n      <\/div>\n\n      <h4 class=\"group\">WordPress stack<\/h4>\n      <div class=\"spec-grid\">\n        <div class=\"spec-card\">\n          <div class=\"name\">wp-landing<\/div>\n          <dl>\n            <dt>Image<\/dt><dd>nginx:alpine<\/dd>\n            <dt>IP<\/dt><dd>172.18.0.14 (nextcloud-aio)<\/dd>\n            <dt>Serves<\/dt><dd>\/ and \/my static cards pages<\/dd>\n          <\/dl>\n        <\/div>\n        <div class=\"spec-card\">\n          <div class=\"name\">wordpress-mylogbook (+ db)<\/div>\n          <dl>\n            <dt>IP<\/dt><dd>172.18.0.13 (nextcloud-aio) + own &#8220;internal&#8221; net<\/dd>\n            <dt>DB<\/dt><dd>wordpress-mylogbook-db, mariadb:11, internal-only net<\/dd>\n            <dt>Path<\/dt><dd>\/my\/mylogbook<\/dd>\n          <\/dl>\n        <\/div>\n        <div class=\"spec-card\">\n          <div class=\"name\">wordpress-mylearning (+ db)<\/div>\n          <dl>\n            <dt>IP<\/dt><dd>172.18.0.15 (nextcloud-aio) + own &#8220;internal&#8221; net<\/dd>\n            <dt>DB<\/dt><dd>wordpress-mylearning-db, mariadb:11, internal-only net<\/dd>\n            <dt>Path<\/dt><dd>\/my\/mylearning<\/dd>\n          <\/dl>\n        <\/div>\n      <\/div>\n    <\/section>\n\n    <section class=\"phase\" id=\"appendix\">\n      <div class=\"phase-head\"><span class=\"phase-num\">A<\/span><h2>Appendix<\/h2><\/div>\n      <p class=\"dek\">Where things live, for whoever&#8217;s grepping this at 2am.<\/p>\n      <table>\n        <tbody><tr><th>Path<\/th><th>What<\/th><\/tr>\n        <tr><td><code>\/mnt\/nextcloud_data<\/code><\/td><td>External drive, all Nextcloud user data<\/td><\/tr>\n        <tr><td><code>~\/docker\/npm\/<\/code><\/td><td>Nginx Proxy Manager compose + data + certs<\/td><\/tr>\n        <tr><td><code>~\/docker\/wp-landing\/<\/code><\/td><td>Static cards pages (root and each sub-hub)<\/td><\/tr>\n        <tr><td><code>~\/docker\/wp-&lt;slug&gt;\/<\/code><\/td><td>One directory per WordPress\/static site, fully independent<\/td><\/tr>\n        <tr><td><code>~\/docker\/portainer\/<\/code><\/td><td>Portainer compose + data \u2014 Docker management UI, Tailscale-only<\/td><\/tr>\n        <tr><td><code>~\/docker\/ADDING-A-SITE.md<\/code><\/td><td>Living step-by-step for the next new site<\/td><\/tr>\n        <tr><td><code>~\/duckdns\/duck.sh<\/code><\/td><td>Dynamic DNS updater, run by <code>duckdns.timer<\/code><\/td><\/tr>\n        <tr><td><code>\/etc\/ssh\/sshd_config.d\/10-hardening.conf<\/code><\/td><td>Key-only SSH override<\/td><\/tr>\n      <\/tbody><\/table>\n      <h3 class=\"step\"><span class=\"dot\"><\/span>Quick health check, any time<\/h3>\n      <pre><code>docker ps --format \"table {{.Names}}\\t{{.Status}}\"\nsudo ufw status verbose\ntailscale status\ndocker exec npm nginx -t<\/code><\/pre>\n\n      <h3 class=\"step\"><span class=\"dot\"><\/span>Reboot resilience check<\/h3>\n      <p>Worth running once after any fresh build, and again any time a service was set up by hand rather than through this guide \u2014 <code>active<\/code> only means it&#8217;s running <em>now<\/em>, not that it&#8217;ll come back after a reboot:<\/p>\n      <pre><code>for s in docker tailscaled ssh ufw duckdns.timer; do\n  echo \"$s: $(systemctl is-enabled $s 2&gt;&amp;1)\"\ndone<\/code><\/pre>\n      <p>Every line should read <code>enabled<\/code>. Container-level survival doesn&#8217;t need separate checking \u2014 anything with <code>restart: always<\/code> or <code>unless-stopped<\/code> 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.<\/p>\n    <\/section>\n\n  <\/main>\n\n  <footer class=\"colophon\">\n    Written from the box&#8217;s actual running configuration, not from memory of how it was set up \u2014 every command here was cross-checked against what&#8217;s live. Placeholders like <span class=\"placeholder\">&lt;slug&gt;<\/span> and <span class=\"placeholder\">&lt;tailscale-ip&gt;<\/span> stand in for values specific to your own build.\n  <\/footer>\n<\/div>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n","protected":false},"excerpt":{"rendered":"<p>A reproducible build order for the box currently running Nextcloud and a small family of WordPress sites \u00e2\u20ac\u201d Tailscale-only administration, one reverse proxy, one external drive for data. Skips the dead ends; keeps only what&#8217;s actually running. Home Server Build Guide \u2014 Ubuntu \u2192 Nextcloud \u2192 WordPress Contents Base OS &amp; access Storage Docker &amp; [&hellip;]<\/p>\n","protected":false},"featured_media":607,"template":"","project_category":[123,152,109,151,153,102,106],"class_list":["post-634","doc_article","type-doc_article","status-publish","has-post-thumbnail","hentry","project_category-docs","project_category-guides","project_category-other-web-v-tutorials","project_category-setup","project_category-ubuntu","project_category-web-v-tutorials","project_category-wordpress"],"_links":{"self":[{"href":"https:\/\/ahaddad-wp.duckdns.org\/my\/mylogbook\/wp-json\/wp\/v2\/doc_article\/634","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/ahaddad-wp.duckdns.org\/my\/mylogbook\/wp-json\/wp\/v2\/doc_article"}],"about":[{"href":"https:\/\/ahaddad-wp.duckdns.org\/my\/mylogbook\/wp-json\/wp\/v2\/types\/doc_article"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/ahaddad-wp.duckdns.org\/my\/mylogbook\/wp-json\/wp\/v2\/media\/607"}],"wp:attachment":[{"href":"https:\/\/ahaddad-wp.duckdns.org\/my\/mylogbook\/wp-json\/wp\/v2\/media?parent=634"}],"wp:term":[{"taxonomy":"project_category","embeddable":true,"href":"https:\/\/ahaddad-wp.duckdns.org\/my\/mylogbook\/wp-json\/wp\/v2\/project_category?post=634"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}