{"id":623,"date":"2026-08-10T18:26:05","date_gmt":"2026-08-10T18:26:05","guid":{"rendered":"https:\/\/ahaddad-wp.duckdns.org\/my\/mylogbook\/?post_type=doc_article&#038;p=623"},"modified":"2026-08-13T21:11:06","modified_gmt":"2026-08-13T18:11:06","slug":"personal-cloud-server-project-nextcloud-aio-duckdns-and-wordpress-hosting-claude-reply","status":"publish","type":"doc_article","link":"https:\/\/ahaddad-wp.duckdns.org\/my\/mylogbook\/docs\/personal-cloud-server-project-nextcloud-aio-duckdns-and-wordpress-hosting-claude-reply\/","title":{"rendered":"Personal Cloud Server Project: Nextcloud AIO, DuckDNS, and WordPress Hosting &#8211; CLAUDE Reply"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\"><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Cleanup done \u2014 hello-world container\/image gone, the stray file removed, Portainer container and its data volume both removed. Docker now shows just the Nextcloud AIO stack.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Now let&#8217;s set up Tailscale \u2014 this is what lets you (and your phone\/laptop) reach the server securely from anywhere, without ever opening an SSH port on your router to the whole internet.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What it is, in plain terms: Tailscale creates a private network between your devices (like a permanent, automatic VPN) using your own login (Google\/Microsoft\/GitHub\/Apple\/email \u2014 whichever you prefer). Once installed, the server gets a private address that only your logged-in devices can reach. Nobody scanning the internet can even see it exists.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Let&#8217;s install it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Ran 1 shell command<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Tailscale is installed. Now I need to start it and get a login link for you to open \u2014 this is the one step that needs your account, so it has to happen in your browser.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Ran 2 shell commands<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s your login link:<\/p>\n\n\n\n<figure class=\"wp-block-embed\"><div class=\"wp-block-embed__wrapper\">\nhttps:\/\/login.tailscale.com\/a\/58f7fed01ad7f\n<\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">What to do: Open that link in a browser on your Windows machine (or your phone \u2014 doesn&#8217;t matter which, it just needs to be a device where you can log in). Tailscale will ask you to sign in \u2014 pick whichever option you like (Google, Microsoft, GitHub, Apple, or email). This creates your free Tailscale account and links this server to it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Important: whatever account you choose here is the one you&#8217;ll also install Tailscale under on your phone and Windows machine afterward, so all your devices can see each other. Pick one you&#8217;ll remember and keep using for this project.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Once you&#8217;ve logged in and see a confirmation page, let me know and I&#8217;ll verify the server joined your network.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">\u203b recap: We&#8217;re setting up your Nextcloud server securely with Tailscale for remote access. Next: open the login link I sent, sign in with any account, and let me know once you see the confirmation page. (disable recaps in \/config)<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">10 tasks (2 done, 1 in progress, 7 open)<br>\u25fc Install and configure Tailscale for remote admin access<br>\u25fb Harden SSH daemon config<br>\u25fb Configure UFW firewall<br>\u25fb Automate DuckDNS IP updates via systemd timer<br>\u25fb Install Nginx Proxy Manager as reverse proxy in front of A\u2026<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Next, add a second device.<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Your device (<strong>ubuntu-server<\/strong>) was added to your network!<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Tailscale connects multiple devices together, so you\u2019ll need it installed on more than one device. Open the link below on a second device and download Tailscale there too.LinuxWindowsmacOSiPhone &amp; iPadAndroidSynology<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Download Tailscale from our website, send yourself an email with a download link, or read our&nbsp;<a href=\"https:\/\/tailscale.com\/download\/windows\" target=\"_blank\" rel=\"noreferrer noopener\">installation guide<\/a>.<\/p>\n\n\n\n<figure class=\"wp-block-embed\"><div class=\"wp-block-embed__wrapper\">\nhttps:\/\/tailscale.com\/download\/windows\n<\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Cleanup done \u2014 hello-world container\/image gone, the stray file removed, Portainer container and its data volume both removed. Docker now shows just the Nextcloud AIO stack.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Now let&#8217;s set up Tailscale \u2014 this is what lets you (and your phone\/laptop) reach the server securely from anywhere, without ever opening an SSH port on your router to the whole internet.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What it is, in plain terms: Tailscale creates a private network between your devices (like a permanent, automatic VPN) using your own login (Google\/Microsoft\/GitHub\/Apple\/email \u2014 whichever you prefer). Once installed, the server gets a private address that only your logged-in devices can reach. Nobody scanning the internet can even see it exists.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Let&#8217;s install it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Ran 1 shell command<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Tailscale is installed. Now I need to start it and get a login link for you to open \u2014 this is the one step that needs your account, so it has to happen in your browser.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Ran 2 shell commands<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s your login link:<\/p>\n\n\n\n<figure class=\"wp-block-embed\"><div class=\"wp-block-embed__wrapper\">\nhttps:\/\/login.tailscale.com\/a\/58f7fed01ad7f\n<\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">What to do: Open that link in a browser on your Windows machine (or your phone \u2014 doesn&#8217;t matter which, it just needs to be a device where you can log in). Tailscale will ask you to sign in \u2014 pick whichever option you like (Google, Microsoft, GitHub, Apple, or email). This creates your free Tailscale account and links this server to it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Important: whatever account you choose here is the one you&#8217;ll also install Tailscale under on your phone and Windows machine afterward, so all your devices can see each other. Pick one you&#8217;ll remember and keep using for this project.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Once you&#8217;ve logged in and see a confirmation page, let me know and I&#8217;ll verify the server joined your network.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">\u273b Baked for 1m 21s \u00b7 1 shell still running<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">\u203b recap: We&#8217;re setting up your Nextcloud server securely with Tailscale for remote access. Next: open the login link I sent, sign in with any account, and let me know once you see the confirmation page. (disable recaps in \/config)<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">\u25cf Background command &#8220;Start Tailscale and generate a login link&#8221; completed (exit code 0)<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Ran 1 shell command<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Tailscale is connected \u2014 the server now has a private Tailscale IP: 100.111.255.36, tied to your account.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Now let&#8217;s get your Windows machine on the same private network so you can actually use it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Step 1 \u2014 Install Tailscale on Windows:<br>Go to https:\/\/tailscale.com\/download\/windows, download and run the installer, then sign in \u2014 use the exact same account you just used for the server (so both devices land on the same private network).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Step 2 \u2014 Test it:<br>Once Tailscale shows &#8220;Connected&#8221; on Windows, open a new terminal window and try:<br>ssh ahmad@100.111.255.36<br>This should log you in exactly like your normal SSH does now, but over the private Tailscale network instead of your LAN\/router.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Let me know once that works \u2014 then we lock down SSH so it&#8217;s only reachable this way (no more direct-internet exposure via those 2222\/16000 router forwards).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">\u273b Brewed for 19s<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">\u276f got tailscale installed on my windows 11 pc (this machine i am at now) it is asking me to either open local settings or open admin console or close. which one i need to do?<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Neither is required right now \u2014 you can safely click Close. Quick explainer so you&#8217;re not left wondering:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Admin console opens a webpage (Tailscale&#8217;s dashboard) where you can see\/manage all your devices. Handy later, not needed now.<\/li>\n\n\n\n<li>Local settings opens this device&#8217;s own Tailscale preferences (things like whether it starts on boot). Also not needed right now.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Click Close, then look for the Tailscale icon in your Windows system tray (bottom-right, near the clock \u2014 might be hidden under the little up-arrow &#8220;show hidden icons&#8221;). Click it and confirm it says Connected.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Once you see that, open a terminal (PowerShell\/Windows Terminal \u2014 a new window, separate from wherever you&#8217;re chatting with me) and run:<br>ssh ahmad@100.111.255.36<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Let me know if it logs you in.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">after installing Tailscale onmy windows machine, i  ran this command in the powershell:<br><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">PS C:\\WINDOWS\\system32&gt; ssh ahmad@100.111.255.36<br>The authenticity of host &#8216;100.111.255.36 (100.111.255.36)&#8217; can&#8217;t be established.<br>ED25519 key fingerprint is SHA256:WdlP1s8Qd+ulZURsIhu3CJSeQ5gaB3s6AitTln2v4qg.<br>This host key is known by the following other names\/addresses:<br>C:\\Users\\ahmad\/.ssh\/known_hosts:2: 192.168.100.13<br>Are you sure you want to continue connecting (yes\/no\/[fingerprint])? yes<br>Warning: Permanently added &#8216;100.111.255.36&#8217; (ED25519) to the list of known hosts.<br>Connection closed by 100.111.255.36 port 22<br>PS C:\\WINDOWS\\system32&gt;<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"550\" src=\"https:\/\/ahaddad-wp.duckdns.org\/my\/mylogbook\/wp-content\/uploads\/2026\/08\/image-1024x550.png\" alt=\"\" class=\"wp-image-625\" srcset=\"https:\/\/ahaddad-wp.duckdns.org\/my\/mylogbook\/wp-content\/uploads\/2026\/08\/image-1024x550.png 1024w, https:\/\/ahaddad-wp.duckdns.org\/my\/mylogbook\/wp-content\/uploads\/2026\/08\/image-300x161.png 300w, https:\/\/ahaddad-wp.duckdns.org\/my\/mylogbook\/wp-content\/uploads\/2026\/08\/image-768x413.png 768w, https:\/\/ahaddad-wp.duckdns.org\/my\/mylogbook\/wp-content\/uploads\/2026\/08\/image-1536x826.png 1536w, https:\/\/ahaddad-wp.duckdns.org\/my\/mylogbook\/wp-content\/uploads\/2026\/08\/image.png 1920w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">SSH over Tailscale is fully working \u2014 you&#8217;re in. That&#8217;s the private, secure path locked down.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Read 1 file<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Since there&#8217;s no SSH key set up yet, and password auth is what&#8217;s actually been under attack, we need to set up key-based login before I turn password auth off \u2014 otherwise a mistake could lock you out entirely.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Step 1 \u2014 Generate a key pair on your Windows machine. In the same PowerShell window (the one you&#8217;re SSH&#8217;d from, or open a fresh one \u2014 doesn&#8217;t matter which), run:<br>ssh-keygen -t ed25519 -C &#8220;ahmad-windows-pc&#8221;<br>It&#8217;ll ask two things:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>&#8220;Enter file in which to save the key&#8221; \u2014 just press Enter to accept the default.<\/li>\n\n\n\n<li>&#8220;Enter passphrase&#8221; \u2014 you can press Enter twice for no passphrase (simplest, fine for a personal machine), or type one if you want extra protection on the key file itself. Your call \u2014 either is fine, no passphrase just means anyone who gets your Windows login could use the key.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Step 2 \u2014 Show me the public key (this half is safe to share \u2014 it&#8217;s not a secret, unlike the private half):<br>type $env:USERPROFILE.ssh\\id_ed25519.pub<br>Paste the full line it prints back here (it&#8217;ll start with ssh-ed25519 AAAA\u2026).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Important: never paste the other file (id_ed25519, no .pub) anywhere \u2014 that one&#8217;s the private key and must stay only on your Windows machine.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Go ahead and run those two commands.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Nginx Account:<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Full Name: Ahmad Elhaddad<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Email address: ahmadhaddad958@gmail.com <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">password: ent-Z1M5<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p class=\"wp-block-paragraph\">Initial Nextcloud username:&nbsp;<strong>admin<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Initial Nextcloud password:&nbsp;<strong>a7dd1672a237101fe5fa68316eb5620d201b97f5719465ca<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">initial passphrase:  postage spotlight unsolved staleness disk dexterous riverside passerby<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Best tools to use for copying media files in bulk byt fast and safe<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">before we start WP. i need you advice about the best fast tools to use for bulk copying from my repository media to this 1.8TB dedicated to Nexcloud (of course not bypassing nextcloud, but with coordination with nextcloud to make sure every media is registerd indexed etc. i tired in the old setting Robocopy, it was great fast but fills the log too quickly and caused hanging the entire setup. i tried rclone, was a bit slower but no problems with the loggin\u2026.but then caused corruption to the entire setup\u2026.the problem, using natively the nextcloud to move the media takes days\u2026.i have more that 700GB worth of media on one drive and about 400+ GB on another drive\u2026.it is going to be christmas before finishing the download \ud83d\ude42<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 1 new message \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Good instinct to ask before diving in \u2014 the tool matters less here than the workflow, and I think I can explain why rclone likely corrupted things so we avoid repeating it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Why the past attempts went wrong, most likely:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Robocopy logging to death: that&#8217;s a Windows\/SMB-network-copy symptom \u2014 verbose per-file logging over a network share can balloon and choke the transfer. Not relevant once we&#8217;re copying directly on Linux.<\/li>\n\n\n\n<li>rclone corruption: almost certainly caused by copying live into Nextcloud&#8217;s data directory while Nextcloud itself was running \u2014 its background jobs (file scanning, thumbnail generation, cron) were reading\/interpreting a partially-written tree at the same time files were still landing, and Nextcloud&#8217;s database (which tracks every file&#8217;s state) got out of sync with what was actually on disk. That mismatch is what corruption in Nextcloud almost always actually is \u2014 not damaged files, but a database that disagrees with the filesystem.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The correct pattern for exactly this (bulk import, not upload):<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Put Nextcloud in maintenance mode first (occ maintenance:mode &#8211;on) \u2014 this stops every background process from touching the filesystem while we&#8217;re actively writing to it. No race conditions possible.<\/li>\n\n\n\n<li>Copy files directly onto disk with rsync, not through Nextcloud at all (not WebDAV, not the sync client \u2014 those are what make native uploads take days, since each file goes through chunking, versioning checks, and a database transaction). rsync -a &#8211;info=progress2 &#8211;partial gives one continuously-updating progress line instead of a log per file, and &#8211;partial means if it&#8217;s interrupted, it resumes instead of restarting.<\/li>\n\n\n\n<li>Fix ownership afterward (chown -R www-data:www-data on what was copied) so Nextcloud&#8217;s own process can read\/manage it.<\/li>\n\n\n\n<li>Take Nextcloud out of maintenance mode, then run occ files:scan \u2014 this is the official, supported way to tell Nextcloud &#8220;go index everything that&#8217;s now on disk.&#8221; It&#8217;s fast (metadata operations, not re-copying), and this is the step that makes your media actually show up, searchable and shareable, in Nextcloud.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">This sidesteps both failure modes entirely \u2014 nothing running concurrently to get confused, and no fragile network-share logging.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One thing that changes my answer: are those two source drives (700GB \/ 400GB+) things you can physically plug into this Ubuntu server directly (USB or SATA), even temporarily? If so, we skip the network entirely and do a local disk-to-disk copy, which will be both the fastest and the most reliable option \u2014 no Windows\/SMB\/Tailscale transfer layer involved at all.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One thing that changes my answer: are those two source drives (700GB \/ 400GB+) things you can physically plug into this Ubuntu server directly (USB or SATA), even temporarily? If so, we skip the network entirely and do a local disk-to-disk copy, which will be both the fastest and the most reliable option \u2014 no Windows\/SMB\/Tailscale transfer layer involved at all.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Got it \u2014 network transfer it is. Given they&#8217;re staying reliable and low-effort matters more than shaving off the last bit of raw speed, here&#8217;s what I&#8217;d actually recommend, and why it avoids repeating both past failures:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Recommended: rsync via WSL on your Windows machine, over the SSH connection we already built.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Why this specific combination:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>No new services on the server \u2014 it rides on the SSH\/Tailscale access already set up and hardened. No Samba, no new firewall rules, no new attack surface for a one-time job.<\/li>\n\n\n\n<li>rsync is resumable and checksums as it goes \u2014 if it gets interrupted 600GB in, it picks back up rather than starting over, and it won&#8217;t silently leave things half-written the way the rclone incident did.<\/li>\n\n\n\n<li>Controlled, sane logging \u2014 one rolling progress line, not a log file that grows until something chokes (that was robocopy&#8217;s actual problem, not the copying itself).<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The setup on your end:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Do you already have WSL (Windows Subsystem for Linux) installed? If not, it&#8217;s one command in an admin PowerShell (wsl &#8211;install), but it requires a reboot to finish \u2014 wanted to check before I have you kick that off, since it&#8217;s a real interruption to your machine, not something to do mid-task by accident.<\/li>\n\n\n\n<li>Once WSL&#8217;s up, we install rsync inside it (one line), and it can already see your Windows drives (D:, E:, wherever the media lives) automatically.<\/li>\n\n\n\n<li>From there, rsync talks straight to the server using your existing SSH key \u2014 no new credentials, nothing new to secure.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">If you&#8217;d rather skip WSL entirely, the fallback is a GUI tool like WinSCP using the same SSH key \u2014 simpler to click through, somewhat less robust for a 1TB+ resumable transfer, but zero setup beyond installing it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Which way do you want to go \u2014 WSL+rsync (a bit more setup, most reliable), or WinSCP (quick to start, simpler)?<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Good context \u2014 since these will stay as ongoing (mostly-read-only) backups, that also means this won&#8217;t be a strict one-time job; you&#8217;ll likely want to sync new additions occasionally too. That&#8217;s another point in rsync&#8217;s favor \u2014 reruns only transfer what&#8217;s new\/changed, not the whole library again.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So, back to the fork: do you already have WSL installed on Windows, or would you rather go the simpler WinSCP route (quicker to start, no reboot needed, a bit less robust for resuming a huge interrupted transfer)?<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So, back to the fork: do you already have WSL installed on Windows, or would you rather go the simpler WinSCP route (quicker to start, no reboot needed, a bit less robust for resuming a huge interrupted transfer)? I do, but i used it once for a small project and then never touched it. so what do yo suggest?<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Good \u2014 no reboot needed then. Let&#8217;s use WSL. Since it also fits how you&#8217;ll use this going forward (occasional syncs of new files, not just once), it&#8217;s worth the extra setup.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Step 1 \u2014 Open WSL. Search &#8220;WSL&#8221; in your Start menu (or open a PowerShell\/Terminal window and type wsl, Enter). You should land on a prompt that looks different from PowerShell \u2014 something like ahmad@YOURPC:~$.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Step 2 \u2014 Install rsync inside it:<br>sudo apt update &amp;&amp; sudo apt install -y rsync<br>(It&#8217;ll ask for the password of whatever Linux user you set up when you first used WSL \u2014 not your Windows password.)<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Step 3 \u2014 Generate a fresh SSH key, specifically inside WSL (not reusing the Windows one \u2014 WSL&#8217;s filesystem permissions on Windows-mounted drives are too loose for SSH&#8217;s liking, so a separate key avoids a whole class of annoying errors):<br>ssh-keygen -t ed25519 -C &#8220;ahmad-wsl&#8221;<br>Enter for the default location, Enter twice for no passphrase (same as before).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Step 4 \u2014 Show me the public key:<br>cat ~\/.ssh\/id_ed25519.pub<br>Paste that line back here and I&#8217;ll authorize it on the server \u2014 then we&#8217;ll test the connection and go find your media drives from inside WSL.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Fixed \u2014 ahmad is now in the www-data group, and the target directory allows group writes (with new subfolders automatically inheriting the right group going forward, so this won&#8217;t need repeating). Since it&#8217;s a fresh permission set, try the same command again from WSL:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">rsync -a &#8211;info=progress2 &#8211;partial -e ssh &#8220;\/mnt\/e\/Family &amp; Friends\/Ajam-000-Wafiq+Fatma&#8221; ahmad@100.111.255.36:\/mnt\/nextcloud_data\/admin\/files\/<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">\u273b Cooked for 29s<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">\u276f ahmad@my7400:\/mnt\/c\/WINDOWS\/system32$ rsync -a &#8211;info=progress2 &#8211;partial -e ssh &#8220;\/mnt\/e\/Family &amp; Friends\/Ajam-000-Wafiq+Fatma&#8221; ahmad@100.111.255.36:\/mnt\/nextcloud_data\/admin\/files\/<br>523,627,223 100% 10.33MB\/s 0:00:48 (xfr#79, to-chk=0\/81)<br>ahmad@my7400:\/mnt\/c\/WINDOWS\/system32$<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Transferred cleanly \u2014 523MB in 48 seconds (~10MB\/s, reasonable over WiFi\/LAN through SSH encryption). Let&#8217;s verify what actually landed and get it registered in Nextcloud.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Ran 1 shell command<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Small miss on my end \u2014 that needs sudo since regular users can&#8217;t reassign file ownership. Let me redo that and move on to registering it with Nextcloud.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Ran 1 shell command<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One structural thing to fix first: since your source top-level folder is Family &amp; Friends, everything should probably nest under a matching folder in Nextcloud too \u2014 otherwise this test folder ends up sitting loose at the top level while everything else lands one level deeper. Let me fix that now before we scale up, and confirm Nextcloud&#8217;s database follows correctly.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Ran 1 shell command<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Same issue as before \u2014 my own shell session predates the group change, so it doesn&#8217;t see it either. Let me just use sudo directly:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Ran 1 shell command<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Confirmed \u2014 database now correctly shows it nested under Family &amp; Friends, with the old path cleanly removed. That pipeline (rsync \u2192 permissions \u2192 scan) is proven end to end. Go check https:\/\/ahaddad.duckdns.org in a browser if you want to see Family &amp; Friends\/Ajam-000-Wafiq+Fatma show up for real.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Now, the actual scale reality: at the ~10MB\/s we just measured, 700GB works out to roughly 18-20 hours of continuous transfer (likely more once it hits the huge folders full of tiny phone-dump photos \u2014 lots of small files are slower per-GB than a few large ones). That&#8217;s the honest number, not something a different tool would meaningfully change \u2014 it&#8217;s governed by your network\/disk speed, not rsync vs. rclone vs. robocopy.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Good news on the coordination side: we don&#8217;t need to lock Nextcloud in maintenance mode for that whole stretch. Nextcloud&#8217;s own background jobs only act on files it already knows about \u2014 it won&#8217;t go poking at content sitting unscanned on disk. We just need to keep doing what we did here: copy first, then scan afterward, never scan mid-copy. So Nextcloud stays fully usable for you the entire time this runs in the background.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two practical things before you kick off the real one:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Stop this Dell desktop from sleeping during the transfer \u2014 Windows Settings \u2192 System \u2192 Power \u2192 set &#8220;Screen and sleep&#8221; to Never (at least &#8220;sleep,&#8221; screen-off is fine) for the duration. If it sleeps mid-transfer, the connection drops (recoverable with &#8211;partial, but no reason to invite the interruption).<\/li>\n\n\n\n<li>Run it from WSL as one job covering everything left in Family &amp; Friends (rsync will skip the folder we already did, it won&#8217;t recopy it):<br>rsync -a &#8211;info=progress2 &#8211;partial -e ssh &#8220;\/mnt\/e\/Family &amp; Friends\/&#8221; &#8220;ahmad@100.111.255.36:\/mnt\/nextcloud_data\/admin\/files\/Family &amp; Friends\/&#8221;<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Kick it off whenever you&#8217;re ready \u2014 it&#8217;ll run for many hours, so this is a &#8220;start it before bed&#8221; kind of job. Let me know once it&#8217;s running (or if it errors early), and I&#8217;ll check in on progress from the server side periodically.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">\u273b Saut\u00e9ed for 2m 7s<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">\u276f yes family &amp; friends shows us very beautifully\u2026<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Great to hear. Whenever you&#8217;re ready, go ahead and kick off the full transfer \u2014 just double check Windows sleep is disabled first, then run:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">rsync -a &#8211;info=progress2 &#8211;partial -e ssh &#8220;\/mnt\/e\/Family &amp; Friends\/&#8221; &#8220;ahmad@100.111.255.36:\/mnt\/nextcloud_data\/admin\/files\/Family &amp; Friends\/&#8221;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It&#8217;ll sit there showing a rolling progress line for many hours \u2014 that&#8217;s normal, not stuck. Once it&#8217;s going, let me know and I&#8217;ll periodically check progress from the server side without you needing to babysit it. When it&#8217;s fully done (or if you want to stop and resume later), just tell me and I&#8217;ll run the chown + occ files:scan pass to register everything with Nextcloud.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">\u273b Worked for 6s<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">\u276f then for tomorrow from work, i need to setup claude also in the powershell like to day. can you write something so that i paste it to you to start tomorrow installing the Tailscale and whatever needed to ssh the ubuntu server?<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s a self-contained prompt you can paste into a fresh Claude Code session tomorrow (whether that&#8217;s on the work PC or anywhere else). I&#8217;ve written it so a Claude instance with zero memory of this conversation can pick up correctly:<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p class=\"wp-block-paragraph\">I have a home Ubuntu server (&#8220;ubuntu-server&#8221;, Ubuntu 26.04 LTS) running Nextcloud AIO behind Nginx Proxy Manager, publicly reachable at https:\/\/ahaddad.duckdns.org. Remote administration is done exclusively via Tailscale \u2014 no SSH is exposed to the internet, and SSH password authentication is disabled server-side (key-only). The server&#8217;s Tailscale IP is 100.111.255.36, SSH user is ahmad.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I&#8217;m now on a different Windows machine and need to set up remote access matching my other devices. Please help me:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Install Tailscale on this machine and log into the same Tailscale account I already use on my other devices.<\/li>\n\n\n\n<li>Generate a new SSH key pair specifically for this machine (ed25519), labeled something like &#8220;ahmad-work-pc&#8221;.<\/li>\n\n\n\n<li>Show me the resulting public key.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Note: this machine won&#8217;t have direct access to the server&#8217;s filesystem to authorize the new key itself \u2014 I&#8217;ll need to take the public key you show me and get it added to \/home\/ahmad\/.ssh\/authorized_keys on the server through a device\/session that already has access.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p class=\"wp-block-paragraph\">One important note on that last step: since a brand-new session can&#8217;t add its own key to the server, you&#8217;ll need to bring that public key back to a place that can \u2014 either paste it into this conversation if you&#8217;re able to reopen it from work, or into any other device\/session that already has working SSH access to the server (this Windows PC or the WSL environment both qualify, since they can sudo and directly edit that file).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Separately, I&#8217;m saving a memory of this project&#8217;s architecture now so that even without you pasting anything, any future Claude Code session I run will already have the key facts (Tailscale-only access, no exposed SSH, NPM reverse proxy setup, external-drive datadir, etc.) \u2014 that should make picking this up later, from any machine, considerably smoother.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Recalled 1 memory, wrote 2 memories<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Memory saved \u2014 that project summary will now surface automatically in future Claude Code sessions on this machine, even without you pasting anything.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To recap where things stand: the paste-able prompt above is ready for tomorrow, and once you kick off that big rsync for the rest of Family &amp; Friends tonight (with Windows sleep disabled), it can run unattended for the many hours it&#8217;ll take. Good stopping point for today \u2014 WordPress planning (task #10) is still waiting whenever you want to pick it up next.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The Complete md setup-guide<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Runbook \u00b7 ahaddad home server<\/p>\n\n\n\n<h1 class=\"wp-block-heading\">From bare metal to three hosted services<\/h1>\n\n\n\n<div class=\"wp-block-file\"><a id=\"wp-block-file--media-c6f4232f-74f7-4872-b8c2-91d9759c729f\" href=\"https:\/\/ahaddad-wp.duckdns.org\/my\/mylogbook\/wp-content\/uploads\/2026\/08\/SETUP-GUIDE-1.html\">SETUP-GUIDE<\/a><a href=\"https:\/\/ahaddad-wp.duckdns.org\/my\/mylogbook\/wp-content\/uploads\/2026\/08\/SETUP-GUIDE-1.html\" class=\"wp-block-file__button wp-element-button\" download aria-describedby=\"wp-block-file--media-c6f4232f-74f7-4872-b8c2-91d9759c729f\">Download<\/a><\/div>\n\n\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 \u2014 Tailscale-only administration, one reverse proxy, one external drive for data. Skips the dead ends; keeps only what&#8217;s actually running.Contents<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><a href=\"#phase-1\">Base OS &amp; access<\/a><\/li>\n\n\n\n<li><a href=\"#phase-2\">Storage<\/a><\/li>\n\n\n\n<li><a href=\"#phase-3\">Docker &amp; Nextcloud AIO<\/a><\/li>\n\n\n\n<li><a href=\"#phase-4\">Reverse proxy &amp; public access<\/a><\/li>\n\n\n\n<li><a href=\"#phase-5\">Adding client devices<\/a><\/li>\n\n\n\n<li><a href=\"#phase-6\">Bulk media import<\/a><\/li>\n\n\n\n<li><a href=\"#phase-7\">WordPress hosting<\/a><\/li>\n\n\n\n<li><a href=\"#system-map\">Full system map<\/a><\/li>\n\n\n\n<li><a href=\"#appendix\">Appendix<\/a><\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">ubuntu-server \u00b7 100.111.255.36InternetRouterforwards 80\/443 only80 \/ 443Nginx Proxy Mgr:80 \/ :443, TLS terminationforwardedNextcloud Apache127.0.0.1:1100WordPress sitesrouted by pathby domainby pathTailscale deviceslaptop \u00b7 phone \u00b7 WSL \u00b7 \u2026SSH :22 \u00b7 NPM admin :81 \u00b7 AIO :8080\/8443bound to the Tailscale IP onlyencrypted, direct \u2014 never touches the routerpublic pathtailnet-only pathTwo 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">01<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Base OS &amp; access<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Everything else assumes key-only SSH and a private admin channel exist before any service goes on the box.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Install Ubuntu Server<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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\n\n<h3 class=\"wp-block-heading\">Lock SSH to key-only<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Add your public key to&nbsp;<code>~\/.ssh\/authorized_keys<\/code>&nbsp;during first login (cloud-init images often force password auth on until you turn it off), then override it explicitly:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># \/etc\/ssh\/sshd_config.d\/10-hardening.conf\nPasswordAuthentication no<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>sudo systemctl reload sshd<\/code>&nbsp;after confirming key-based login works in a&nbsp;<em>second<\/em>&nbsp;terminal \u2014 don&#8217;t close the first one until the second one succeeds.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Install Tailscale on the server<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -fsSL https:\/\/tailscale.com\/install.sh | sh\nsudo tailscale up<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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&nbsp;<code>0.0.0.0<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Firewall baseline<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><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\n\n\n<p class=\"wp-block-paragraph\">Why<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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\n\n\n<p class=\"wp-block-paragraph\">Ports 80\/443 get opened in Phase 4, once there&#8217;s a reverse proxy actually listening on them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">02<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Storage<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Application data lives on the external drive; the boot NVMe only ever holds the OS and Docker.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Mount the external drive<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Find its UUID with&nbsp;<code>sudo blkid<\/code>, then add a stable&nbsp;<code>fstab<\/code>&nbsp;entry:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># \/etc\/fstab\nUUID=&lt;drive-uuid&gt; \/mnt\/nextcloud_data ext4 defaults,noatime,nofail 0 2<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>nofail<\/code>&nbsp;matters \u2014 without it, a missing external drive at boot can hang the whole system waiting on the mount.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo mkdir -p \/mnt\/nextcloud_data\nsudo mount -a<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Group convention for shared write access<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The Nextcloud container&#8217;s process runs as&nbsp;<code>www-data<\/code>&nbsp;inside the container, which maps to a real&nbsp;<code>www-data<\/code>&nbsp;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\n\n\n<pre class=\"wp-block-code\"><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\n\n\n<p class=\"wp-block-paragraph\">Gotcha<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Group membership is read at login. A shell session opened&nbsp;<em>before<\/em>&nbsp;this command won&#8217;t see the new group \u2014 reconnect rather than trying to refresh it in place.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">03<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Docker &amp; Nextcloud AIO<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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\n\n<h3 class=\"wp-block-heading\">Install Docker Engine<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -fsSL https:\/\/get.docker.com | sh\nsudo usermod -aG docker $USER\n# reconnect for the group to apply<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Run the AIO mastercontainer<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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:&nbsp;<code>NEXTCLOUD_DATADIR<\/code>&nbsp;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\n\n\n<pre class=\"wp-block-code\"><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\n\n\n<p class=\"wp-block-paragraph\"><code>APACHE_PORT=1100<\/code>&nbsp;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&nbsp;<code>127.0.0.1<\/code>&nbsp;only and is never reached directly.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Complete setup over Tailscale<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">From any device on the tailnet, open&nbsp;<code>https:\/\/&lt;tailscale-ip&gt;:8443<\/code>&nbsp;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\n\n<h3 class=\"wp-block-heading\">Install Portainer (Docker management UI)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Same private-admin pattern as everything else \u2014 bound to the Tailscale IP only, never public:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><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\n\n\n<pre class=\"wp-block-code\"><code>cd ~\/docker\/portainer &amp;&amp; docker compose up -d<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Gotcha<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Portainer locks its own setup screen&nbsp;<strong>5 minutes<\/strong>&nbsp;after first start if no admin account has been created yet (&#8220;the instance timed out for security purposes&#8221;) \u2014 if that happens,&nbsp;<code>docker restart portainer<\/code>&nbsp;resets the timer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Recent Portainer versions also require a one-time&nbsp;<strong>setup token<\/strong>&nbsp;pasted into the initial admin-creation screen, printed only in the container&#8217;s startup logs:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>docker logs portainer 2&gt;&amp;1 | grep setup_token<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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\n\n\n<p class=\"wp-block-paragraph\">04<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Reverse proxy &amp; public access<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">One entry point for the whole internet-facing surface: Nginx Proxy Manager terminates TLS and decides where every request actually goes.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Run Nginx Proxy Manager<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">On the same Docker network as the Nextcloud containers, so it can reach them by container name:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><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\n\n\n<pre class=\"wp-block-code\"><code>cd ~\/docker\/npm &amp;&amp; docker compose up -d<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Its own admin UI (port&nbsp;<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\n\n<h3 class=\"wp-block-heading\">Dynamic DNS<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A free DuckDNS hostname, kept current by a systemd timer rather than cron (survives reboots cleanly, logs to journald):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><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\n\n\n<pre class=\"wp-block-code\"><code># \/etc\/systemd\/system\/duckdns.service\n&#91;Unit]\nDescription=Update DuckDNS IP address\nWants=network-online.target\nAfter=network-online.target\n\n&#91;Service]\nType=oneshot\nUser=&lt;you&gt;\nExecStart=\/home\/&lt;you&gt;\/duckdns\/duck.sh\n\n# \/etc\/systemd\/system\/duckdns.timer\n&#91;Unit]\nDescription=Run DuckDNS update every 5 minutes\n\n&#91;Timer]\nOnBootSec=1min\nOnUnitActiveSec=5min\nPersistent=true\n\n&#91;Install]\nWantedBy=timers.target<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>chmod 700 ~\/duckdns\/duck.sh\nsudo systemctl enable --now duckdns.timer<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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\n\n<h3 class=\"wp-block-heading\">Router port forward<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Forward&nbsp;<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\n\n<h3 class=\"wp-block-heading\">Proxy host + certificate<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">In NPM&#8217;s admin UI:&nbsp;<strong>Proxy Hosts \u2192 Add Proxy Host<\/strong>&nbsp;\u2014 domain name, forward to the Nextcloud Apache container on port&nbsp;<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\n\n<h3 class=\"wp-block-heading\">Open the firewall for real traffic<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo ufw allow 80\/tcp\nsudo ufw allow 443\/tcp<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The full, final rule set looks like this:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><th class=\"has-text-align-left\" data-align=\"left\">Rule<\/th><th class=\"has-text-align-left\" data-align=\"left\">Purpose<\/th><\/tr><tr><td class=\"has-text-align-left\" data-align=\"left\"><code>tailscale0<\/code>&nbsp;\u2192 allow all<\/td><td class=\"has-text-align-left\" data-align=\"left\">Every private admin surface<\/td><\/tr><tr><td class=\"has-text-align-left\" data-align=\"left\"><code>41641\/udp<\/code>&nbsp;\u2192 allow<\/td><td class=\"has-text-align-left\" data-align=\"left\">Tailscale direct (NAT-traversed) connections<\/td><\/tr><tr><td class=\"has-text-align-left\" data-align=\"left\"><code>22\/tcp<\/code>&nbsp;from LAN subnet \u2192 allow<\/td><td class=\"has-text-align-left\" data-align=\"left\">SSH fallback if Tailscale is down<\/td><\/tr><tr><td class=\"has-text-align-left\" data-align=\"left\"><code>80\/tcp<\/code>,&nbsp;<code>443\/tcp<\/code>&nbsp;\u2192 allow<\/td><td class=\"has-text-align-left\" data-align=\"left\">Public HTTP\/HTTPS, handled entirely by NPM<\/td><\/tr><tr><td class=\"has-text-align-left\" data-align=\"left\">everything else \u2192 deny<\/td><td class=\"has-text-align-left\" data-align=\"left\">Default posture<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">05<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Adding client devices<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The same two-step pattern for every new laptop, phone, or WSL environment that needs to administer the box.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Install Tailscale on the new device, sign into the same tailnet account.<\/li>\n\n\n\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\n\n\n<li>Get the new public key onto the server via a device\/session that&nbsp;<em>already<\/em>&nbsp;has access, appended to&nbsp;<code>~\/.ssh\/authorized_keys<\/code>.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Note<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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\n\n\n<p class=\"wp-block-paragraph\">06<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Bulk media import<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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\n\n<h3 class=\"wp-block-heading\">Open up write access first<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Nextcloud&#8217;s own files are owned by&nbsp;<code>www-data<\/code>&nbsp;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\n\n\n<pre class=\"wp-block-code\"><code>sudo chmod g+w \"\/mnt\/nextcloud_data\/admin\/files\/&lt;target folder&gt;\"<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Copy in with rsync, not the web UI<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Run from whichever machine actually holds the source files, over SSH to the server:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><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\n\n\n<p class=\"wp-block-paragraph\">Why these flags<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A non-root SSH user can&#8217;t&nbsp;<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&nbsp;<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\n\n\n<p class=\"wp-block-paragraph\">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\n\n<h3 class=\"wp-block-heading\">Two filesystem limits worth knowing before they surprise you<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Gotcha<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Unicode normalization.<\/strong>&nbsp;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\n\n\n<pre class=\"wp-block-code\"><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\n\n\n<p class=\"wp-block-paragraph\"><strong>Filename length.<\/strong>&nbsp;ext4 caps individual filenames at 255&nbsp;<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\n\n\n<h3 class=\"wp-block-heading\">Fix ownership, then register with Nextcloud<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><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\n\n\n<p class=\"wp-block-paragraph\">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&nbsp;<code>Errors<\/code>&nbsp;column should read&nbsp;<code>0<\/code>; anything else is almost always one of the two gotchas above.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">07<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">WordPress hosting<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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\n\n<h3 class=\"wp-block-heading\">The shape of it<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><code>ahaddad-wp.duckdns.org\/<\/code>&nbsp;is a static cards page (its own tiny&nbsp;<code>nginx:alpine<\/code>&nbsp;container, no database).&nbsp;<code>\/my<\/code>&nbsp;is another static cards page, one level down.&nbsp;<code>\/my\/mylogbook<\/code>&nbsp;and&nbsp;<code>\/my\/mylearning<\/code>&nbsp;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\n\n<h3 class=\"wp-block-heading\">A WordPress install that knows it lives in a subpath<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Two things make a normal WordPress container work correctly under&nbsp;<code>\/my\/&lt;slug&gt;<\/code>&nbsp;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\n\n\n<pre class=\"wp-block-code\"><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\n\n\n<pre class=\"wp-block-code\"><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: &#91;internal, nextcloud-aio]\n    depends_on: &#91;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: &#91;.\/db-data:\/var\/lib\/mysql]\n    networks: &#91;internal]\n\nnetworks:\n  internal:\n  nextcloud-aio:\n    external: true<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Generate the two passwords into&nbsp;<code>.env<\/code>&nbsp;before starting it \u2014 never hard-code them into the compose file itself:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>openssl rand -base64 24 | tr -d '\/+=' | head -c 32   # run twice, once per secret<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Route it in NPM<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Every site is one&nbsp;<strong>Custom Location<\/strong>&nbsp;on the single existing proxy host \u2014 not a new proxy host each time.&nbsp;<code>Proxy Hosts \u2192 ahaddad-wp.duckdns.org \u2192 Edit \u2192 Custom Locations \u2192 Add location<\/code>: path&nbsp;<code>\/my\/&lt;slug&gt;<\/code>, forward to&nbsp;<code>wordpress-&lt;slug&gt;<\/code>&nbsp;on port&nbsp;<code>80<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Gotcha<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Nginx resolves upstream hostnames&nbsp;<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&nbsp;<code>docker compose up -d<\/code>&nbsp;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>),&nbsp;<em>then<\/em>&nbsp;add the NPM location.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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\n\n\n<pre class=\"wp-block-code\"><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\n\n<h3 class=\"wp-block-heading\">Static sites, the lighter version<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">No database, no Apache alias trick \u2014 just a bind-mounted folder behind nginx:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>services:\n  site:\n    image: nginx:alpine\n    container_name: wp-&lt;slug&gt;\n    restart: always\n    volumes: &#91;.\/html:\/usr\/share\/nginx\/html:ro]\n    networks: &#91;nextcloud-aio]\n\nnetworks:\n  nextcloud-aio:\n    external: true<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If it&#8217;s built by a static-site generator, set its base\/public-path build option to&nbsp;<code>\/my\/&lt;slug&gt;<\/code>&nbsp;so its own internal links resolve correctly \u2014 the build-time equivalent of&nbsp;<code>WP_HOME<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The landing pages<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Plain static HTML, one card per site, no build step \u2014 edit and the change is live on next request:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><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\n\n\n<p class=\"wp-block-paragraph\">08<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Full system map<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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\n\n<h3 class=\"wp-block-heading\">Layout<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">ubuntu-serverDocker EngineInternetRouterforwards 80\/443 only80\/443DuckDNSahaddad \u00b7 ahaddad-wpresolves toNginx Proxy Mgr:80 \/ :443 public\ud83d\udd12 :81 adminforwardedPortainermanages this layer\ud83d\udd12 :9443docker.sockNextcloud AIO10 containersexternal-drive datadir\ud83d\udd12 :8080 \/ :8443Apache 127.0.0.1:1100by domainWordPresslanding + 2 siteseach own MariaDBno public admin portby pathSSH :22 \u2014 OS-level, not containerized\ud83d\udd12 tailscale + LAN fallback onlyTailscale deviceslaptop \u00b7 phone \u00b7 WSL \u00b7 \u2026direct to every \ud83d\udd12 portpublic pathcontrol \/ DNStailnet-onlyEverything 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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Every box, labeled<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Same map, with the exact address, port, and access method behind each piece.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Edge &amp; DNS \u2014 not containers, OS\/network level<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">DuckDNSHostsahaddad.duckdns.org, ahaddad-wp.duckdns.orgResolves to178.153.184.130 (dynamic)Kept current byduckdns.timer, every 5 minScript~\/duckdns\/duck.sh<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">RouterWAN178.153.184.130 (dynamic)Forwards80\/tcp, 443\/tcp \u2192 192.168.100.13Everything elsenot forwarded<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">SSH\ud83d\udd12 restrictedPort22\/tcpAuthkey-only (PasswordAuthentication no)Allowed fromtailscale0 (anywhere) + 192.168.100.0\/24Config\/etc\/ssh\/sshd_config.d\/10-hardening.conf<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Reverse proxy &amp; management<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">npm\ud83d\udd12 :81Imagejc21\/nginx-proxy-manager:latestNetworknextcloud-aio \u00b7 172.18.0.12Public0.0.0.0:80, 0.0.0.0:443Admin100.111.255.36:81Host 1ahaddad.duckdns.org \u2192 nextcloud-aio-apache:1100Host 2ahaddad-wp.duckdns.org \u2192 landing:80, + \/my\/mylogbook, \/my\/mylearning<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">portainer\ud83d\udd12 :9443Imageportainer\/portainer-ce:latestNetworkportainer_default \u00b7 172.21.0.2Admin100.111.255.36:9443Mounts\/var\/run\/docker.sock (rw) \u2014 manages every container on the host<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Nextcloud AIO stack \u2014 network: nextcloud-aio (172.18.0.0\/16)<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">mastercontainer\ud83d\udd12 :8080\/:8443IP172.18.0.2Admin100.111.255.36:8080, :8443Roleowns docker.sock (ro), manages the other 9 AIO containers<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">apacheIP172.18.0.11Reached by NPMvia docker network, port 1100Host mapping127.0.0.1:1100 (local debug only)<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">nextcloud (app)IP172.18.0.10Port9000, internal only<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">database (postgres)IP172.18.0.7Port5432, internal only<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">redisIP172.18.0.8Port6379, internal only<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">talkIP172.18.0.4Public0.0.0.0:3478 tcp+udp (TURN\/STUN)<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">imaginary \u00b7 notify-push \u00b7 whiteboard \u00b7 euroofficeIPs172.18.0.9, .5, .6, .3Portsinternal only, no host mapping<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">data directoryHost path\/mnt\/nextcloud_dataDeviceexternal drive, ext4, fstab + nofail<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">WordPress stack<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">wp-landingImagenginx:alpineIP172.18.0.14 (nextcloud-aio)Serves\/ and \/my static cards pages<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">wordpress-mylogbook (+ db)IP172.18.0.13 (nextcloud-aio) + own &#8220;internal&#8221; netDBwordpress-mylogbook-db, mariadb:11, internal-only netPath\/my\/mylogbook<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">wordpress-mylearning (+ db)IP172.18.0.15 (nextcloud-aio) + own &#8220;internal&#8221; netDBwordpress-mylearning-db, mariadb:11, internal-only netPath\/my\/mylearning<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Appendix<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Where things live, for whoever&#8217;s grepping this at 2am.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><th class=\"has-text-align-left\" data-align=\"left\">Path<\/th><th class=\"has-text-align-left\" data-align=\"left\">What<\/th><\/tr><tr><td class=\"has-text-align-left\" data-align=\"left\"><code>\/mnt\/nextcloud_data<\/code><\/td><td class=\"has-text-align-left\" data-align=\"left\">External drive, all Nextcloud user data<\/td><\/tr><tr><td class=\"has-text-align-left\" data-align=\"left\"><code>~\/docker\/npm\/<\/code><\/td><td class=\"has-text-align-left\" data-align=\"left\">Nginx Proxy Manager compose + data + certs<\/td><\/tr><tr><td class=\"has-text-align-left\" data-align=\"left\"><code>~\/docker\/wp-landing\/<\/code><\/td><td class=\"has-text-align-left\" data-align=\"left\">Static cards pages (root and each sub-hub)<\/td><\/tr><tr><td class=\"has-text-align-left\" data-align=\"left\"><code>~\/docker\/wp-&lt;slug&gt;\/<\/code><\/td><td class=\"has-text-align-left\" data-align=\"left\">One directory per WordPress\/static site, fully independent<\/td><\/tr><tr><td class=\"has-text-align-left\" data-align=\"left\"><code>~\/docker\/portainer\/<\/code><\/td><td class=\"has-text-align-left\" data-align=\"left\">Portainer compose + data \u2014 Docker management UI, Tailscale-only<\/td><\/tr><tr><td class=\"has-text-align-left\" data-align=\"left\"><code>~\/docker\/ADDING-A-SITE.md<\/code><\/td><td class=\"has-text-align-left\" data-align=\"left\">Living step-by-step for the next new site<\/td><\/tr><tr><td class=\"has-text-align-left\" data-align=\"left\"><code>~\/duckdns\/duck.sh<\/code><\/td><td class=\"has-text-align-left\" data-align=\"left\">Dynamic DNS updater, run by&nbsp;<code>duckdns.timer<\/code><\/td><\/tr><tr><td class=\"has-text-align-left\" data-align=\"left\"><code>\/etc\/ssh\/sshd_config.d\/10-hardening.conf<\/code><\/td><td class=\"has-text-align-left\" data-align=\"left\">Key-only SSH override<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">Quick health check, any time<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>docker ps --format \"table {{.Names}}\\t{{.Status}}\"\nsudo ufw status verbose\ntailscale status\ndocker exec npm nginx -t<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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&nbsp;&lt;slug&gt;&nbsp;and&nbsp;&lt;tailscale-ip&gt;&nbsp;stand in for values specific to your own build.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Portainer<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/100.111.255.36:9443\">https:\/\/100.111.255.36:9443<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">username: admin<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Pass: ent-Z1M5<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Key: 942103f53d974eac9ad2b3a4647db527ff0be68c0b660e62368ba28244b66341<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Cleanup done \u2014 hello-world container\/image gone, the stray file removed, Portainer container and its data volume both removed. Docker now shows just the Nextcloud AIO stack. Now let&#8217;s set up Tailscale \u2014 this is what lets you (and your phone\/laptop) reach the server securely from anywhere, without ever opening an SSH port on your router [&hellip;]<\/p>\n","protected":false},"featured_media":607,"template":"","project_category":[],"class_list":["post-623","doc_article","type-doc_article","status-publish","has-post-thumbnail","hentry"],"_links":{"self":[{"href":"https:\/\/ahaddad-wp.duckdns.org\/my\/mylogbook\/wp-json\/wp\/v2\/doc_article\/623","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=623"}],"wp:term":[{"taxonomy":"project_category","embeddable":true,"href":"https:\/\/ahaddad-wp.duckdns.org\/my\/mylogbook\/wp-json\/wp\/v2\/project_category?post=623"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}