<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
	<id>https://mediawiki.comfac.net/index.php?action=history&amp;feed=atom&amp;title=NPM_Migration_to_Homelab_VPS_Relay_260401</id>
	<title>NPM Migration to Homelab VPS Relay 260401 - Revision history</title>
	<link rel="self" type="application/atom+xml" href="https://mediawiki.comfac.net/index.php?action=history&amp;feed=atom&amp;title=NPM_Migration_to_Homelab_VPS_Relay_260401"/>
	<link rel="alternate" type="text/html" href="https://mediawiki.comfac.net/index.php?title=NPM_Migration_to_Homelab_VPS_Relay_260401&amp;action=history"/>
	<updated>2026-06-05T11:02:17Z</updated>
	<subtitle>Revision history for this page on the wiki</subtitle>
	<generator>MediaWiki 1.45.1</generator>
	<entry>
		<id>https://mediawiki.comfac.net/index.php?title=NPM_Migration_to_Homelab_VPS_Relay_260401&amp;diff=215&amp;oldid=prev</id>
		<title>Justinaquino: &quot;Add NPM migration to homelab VPS relay guide&quot;</title>
		<link rel="alternate" type="text/html" href="https://mediawiki.comfac.net/index.php?title=NPM_Migration_to_Homelab_VPS_Relay_260401&amp;diff=215&amp;oldid=prev"/>
		<updated>2026-03-31T18:20:36Z</updated>

		<summary type="html">&lt;p&gt;&amp;quot;Add NPM migration to homelab VPS relay guide&amp;quot;&lt;/p&gt;
&lt;p&gt;&lt;b&gt;New page&lt;/b&gt;&lt;/p&gt;&lt;div&gt;= Migrating NGINX Proxy Manager Off a Compromised VPS =&lt;br /&gt;
&lt;br /&gt;
&amp;#039;&amp;#039;A practical runbook for moving NPM to your homelab and turning your VPS into a pure relay.&amp;#039;&amp;#039;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Background and Lessons Learned ==&lt;br /&gt;
&lt;br /&gt;
This tutorial documents a real incident where a VPS running NGINX Proxy Manager (NPM) became compromised. The infection vector was a Minecraft server setup tutorial that turned out to be malware — it used the server&amp;#039;s open Postfix port to send approximately 28,000 spam emails before being detected.&lt;br /&gt;
&lt;br /&gt;
&amp;#039;&amp;#039;&amp;#039;The core lesson:&amp;#039;&amp;#039;&amp;#039; Never install experimental software directly on a bare server. Use Distrobox containers or Docker for anything you are testing or following a tutorial for. The host OS should only run what you deliberately chose to put there.&lt;br /&gt;
&lt;br /&gt;
&amp;#039;&amp;#039;&amp;#039;The architectural lesson:&amp;#039;&amp;#039;&amp;#039; NPM is surprisingly easy to back up and restore. This means you do not need to keep it on an expensive VPS. A cheap VPS can be reduced to a pure traffic relay, with NPM and all services running on hardware you own at home.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Architecture Overview ==&lt;br /&gt;
&lt;br /&gt;
&amp;lt;pre&amp;gt;&lt;br /&gt;
Internet → VPS (pure relay, iptables DNAT) → ZeroTier → Home server NPM → your services&lt;br /&gt;
&amp;lt;/pre&amp;gt;&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Component !! Role&lt;br /&gt;
|-&lt;br /&gt;
| VPS (e.g. Contabo) || Public IP relay only — iptables forwards port 80/443 over ZeroTier&lt;br /&gt;
|-&lt;br /&gt;
| PC03 (primary) || Runs NPM + all Docker services&lt;br /&gt;
|-&lt;br /&gt;
| PC02 (warm backup) || NPM mirror, ready to take over&lt;br /&gt;
|-&lt;br /&gt;
| Synology NAS || Always-on backup repository, watchdog, cold archive&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
The failover flow is: &amp;#039;&amp;#039;&amp;#039;detect&amp;#039;&amp;#039;&amp;#039; (PC06 watchdog, 3 fails trigger) → &amp;#039;&amp;#039;&amp;#039;wake&amp;#039;&amp;#039;&amp;#039; (PC06 → Synology sends WoL → PC02 boots) → &amp;#039;&amp;#039;&amp;#039;failover&amp;#039;&amp;#039;&amp;#039; (DNAT swaps to PC02 once NPM responds). Restic runs 3×/day to Synology from PC03, and mirrors to PC02 on wake.&lt;br /&gt;
&lt;br /&gt;
=== Why this works without a static home IP ===&lt;br /&gt;
&lt;br /&gt;
Your home ISP likely does not give you a static public IP. That does not matter here. Both the VPS and your home servers are connected via ZeroTier, which assigns fixed private IPs regardless of what your ISP does. The VPS always knows where to forward traffic.&lt;br /&gt;
&lt;br /&gt;
=== Approximate hardware costs (Philippine Peso) ===&lt;br /&gt;
&lt;br /&gt;
{| class=&amp;quot;wikitable&amp;quot;&lt;br /&gt;
|-&lt;br /&gt;
! Hardware !! Cost&lt;br /&gt;
|-&lt;br /&gt;
| VPS (e.g. Contabo, annual) || ₱5,000/yr&lt;br /&gt;
|-&lt;br /&gt;
| Dell OptiPlex micro (16GB RAM, 256GB SSD) × 2 || ₱10,000 each&lt;br /&gt;
|-&lt;br /&gt;
| Synology NAS with 2 × 3.5TB IronWolf || ₱28,000&lt;br /&gt;
|}&lt;br /&gt;
&lt;br /&gt;
A system good for 7–10 years, with full redundancy, for roughly what a single mid-tier cloud server costs annually.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Part 1 — Backing Up NPM ==&lt;br /&gt;
&lt;br /&gt;
=== Step 1 — Identify your NPM setup ===&lt;br /&gt;
&lt;br /&gt;
First confirm whether your NPM is using SQLite (simpler) or MariaDB (more common in older installs):&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker ps&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
If you see a separate &amp;lt;code&amp;gt;mariadb&amp;lt;/code&amp;gt; or &amp;lt;code&amp;gt;mariadb-aria&amp;lt;/code&amp;gt; container alongside NPM, you are on the MariaDB variant. This tutorial covers that setup.&lt;br /&gt;
&lt;br /&gt;
Find your compose file:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
find /root /opt /etc/docker -name &amp;quot;docker-compose.yml&amp;quot; 2&amp;gt;/dev/null&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Check where NPM mounts its data:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
docker inspect &amp;lt;npm-container-name&amp;gt; | grep -A 20 &amp;#039;&amp;quot;Mounts&amp;quot;&amp;#039;&lt;br /&gt;
docker inspect &amp;lt;db-container-name&amp;gt;  | grep -A 20 &amp;#039;&amp;quot;Mounts&amp;quot;&amp;#039;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Note the &amp;lt;code&amp;gt;Source&amp;lt;/code&amp;gt; paths — everything NPM needs is in those directories.&lt;br /&gt;
&lt;br /&gt;
=== Step 2 — Create the backup ===&lt;br /&gt;
&lt;br /&gt;
Run this as root. Replace &amp;lt;code&amp;gt;/data/compose/3&amp;lt;/code&amp;gt; with your actual mount paths if different.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Create staging directory&lt;br /&gt;
mkdir -p /root/npm-backup/zt&lt;br /&gt;
&lt;br /&gt;
# Back up ZeroTier identity (preserves your ZT IP after reformat)&lt;br /&gt;
cp /var/lib/zerotier-one/identity.secret /root/npm-backup/zt/&lt;br /&gt;
cp /var/lib/zerotier-one/identity.public  /root/npm-backup/zt/&lt;br /&gt;
&lt;br /&gt;
# Back up the compose file&lt;br /&gt;
cp /root/nginx-pm&amp;lt;your-folder&amp;gt;/docker-compose.yml /root/npm-backup/&lt;br /&gt;
&lt;br /&gt;
# Dump the database (do this while containers are running)&lt;br /&gt;
docker exec &amp;lt;db-container-name&amp;gt; \&lt;br /&gt;
  mysqldump -u npm -pnpm npm &amp;gt; /root/npm-backup/npm-db-dump.sql&lt;br /&gt;
&lt;br /&gt;
# Archive all NPM data (app config, SSL certs, MySQL files)&lt;br /&gt;
tar -czvf /root/npm-backup/npm-data.tar.gz /data/compose/3/&lt;br /&gt;
&lt;br /&gt;
# Verify&lt;br /&gt;
ls -lh /root/npm-backup/&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Check that &amp;lt;code&amp;gt;npm-db-dump.sql&amp;lt;/code&amp;gt; is not zero bytes. If it is, the dump failed silently — the raw MySQL files in &amp;lt;code&amp;gt;npm-data.tar.gz&amp;lt;/code&amp;gt; will be your restore source instead.&lt;br /&gt;
&lt;br /&gt;
=== Step 3 — Transfer to your NAS or local machine ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
rsync -avz --progress \&lt;br /&gt;
  /root/npm-backup/ \&lt;br /&gt;
  user@&amp;lt;NAS-IP&amp;gt;:/volume1/backups/npm/&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Or compress first:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd /root&lt;br /&gt;
tar -czvf npm-backup.tar.gz npm-backup/&lt;br /&gt;
rsync -avz --progress npm-backup.tar.gz user@&amp;lt;NAS-IP&amp;gt;:/volume1/backups/npm/&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Part 2 — Restoring NPM on a New Machine ==&lt;br /&gt;
&lt;br /&gt;
All commands run as root. The backup file goes in &amp;lt;code&amp;gt;/root/&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
&amp;#039;&amp;#039;&amp;#039;Important:&amp;#039;&amp;#039;&amp;#039; NPM data always restores to the same absolute path (&amp;lt;code&amp;gt;/data/compose/3/&amp;lt;/code&amp;gt;) regardless of which machine you are on. This is hardcoded in the compose file. Do not change it.&lt;br /&gt;
&lt;br /&gt;
=== Step 1 — Extract the backup ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
cd /root&lt;br /&gt;
tar -xzvf npm-backup.tar.gz&lt;br /&gt;
ls npm-backup/&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
You should see: &amp;lt;code&amp;gt;docker-compose.yml&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;npm-data.tar.gz&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;npm-db-dump.sql&amp;lt;/code&amp;gt;, &amp;lt;code&amp;gt;zt/&amp;lt;/code&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Step 2 — Restore data files ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Extract directly to / — this recreates /data/compose/3/ automatically&lt;br /&gt;
tar -xzvf /root/npm-backup/npm-data.tar.gz -C /&lt;br /&gt;
&lt;br /&gt;
# Verify&lt;br /&gt;
ls /data/compose/3/&lt;br /&gt;
# Should show: data  letsencrypt  mysql&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Step 3 — Deploy the stack ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
mkdir -p /root/nginx-pm&lt;br /&gt;
cp /root/npm-backup/docker-compose.yml /root/nginx-pm/&lt;br /&gt;
&lt;br /&gt;
cd /root/nginx-pm&lt;br /&gt;
docker compose up -d&lt;br /&gt;
&lt;br /&gt;
docker ps&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Both the NPM app container and the MariaDB container should show as running.&lt;br /&gt;
&lt;br /&gt;
=== Step 4 — Verify ===&lt;br /&gt;
&lt;br /&gt;
Open &amp;lt;code&amp;gt;http://&amp;amp;lt;machine-IP&amp;amp;gt;:81&amp;lt;/code&amp;gt; in a browser. Your proxy hosts, SSL certificates, and settings should all be present exactly as they were.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Part 3 — Setting Up the VPS as a Pure Relay ==&lt;br /&gt;
&lt;br /&gt;
After reformatting your VPS, the entire setup is:&lt;br /&gt;
&lt;br /&gt;
=== Step 1 — Install ZeroTier and join your network ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
curl -s https://install.zerotier.com | bash&lt;br /&gt;
zerotier-cli join &amp;lt;your-network-id&amp;gt;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Then go to your ZeroTier Central dashboard, authorize the new node, and assign it your chosen fixed IP (e.g. the same IP it had before).&lt;br /&gt;
&lt;br /&gt;
&amp;#039;&amp;#039;&amp;#039;Note on ZeroTier IP preservation:&amp;#039;&amp;#039;&amp;#039; You do not need to restore the ZeroTier identity files to keep the same IP. Simply authorize the new node in ZT Central and manually assign the same managed IP there. The identity restore is only needed if you specifically want the same node ID.&lt;br /&gt;
&lt;br /&gt;
&amp;#039;&amp;#039;&amp;#039;Contabo UI warning:&amp;#039;&amp;#039;&amp;#039; Contabo&amp;#039;s control panel is easy to misclick. Always double-check which server you have selected before clicking reformat. The interface can make it easy to accidentally act on the wrong server.&lt;br /&gt;
&lt;br /&gt;
=== Step 2 — Enable IP forwarding and set up relay rules ===&lt;br /&gt;
&lt;br /&gt;
Replace &amp;lt;code&amp;gt;192.168.196.76&amp;lt;/code&amp;gt; with your home server&amp;#039;s ZeroTier IP.&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
# Enable forwarding&lt;br /&gt;
echo &amp;quot;net.ipv4.ip_forward=1&amp;quot; &amp;gt;&amp;gt; /etc/sysctl.conf&lt;br /&gt;
sysctl -p&lt;br /&gt;
&lt;br /&gt;
# Forward web traffic to home NPM&lt;br /&gt;
iptables -t nat -A PREROUTING -p tcp --dport 80  -j DNAT --to-destination 192.168.196.76:80&lt;br /&gt;
iptables -t nat -A PREROUTING -p tcp --dport 443 -j DNAT --to-destination 192.168.196.76:443&lt;br /&gt;
iptables -t nat -A POSTROUTING -j MASQUERADE&lt;br /&gt;
&lt;br /&gt;
# Make rules persistent across reboots&lt;br /&gt;
apt install iptables-persistent -y&lt;br /&gt;
netfilter-persistent save&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
=== Step 3 — Verify ===&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;bash&amp;quot;&amp;gt;&lt;br /&gt;
iptables -t nat -L -n -v&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
You should see packet counters incrementing on the DNAT rules as traffic arrives.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Part 4 — Hardening WordPress Behind NPM ==&lt;br /&gt;
&lt;br /&gt;
After migrating, a brute force attack on &amp;lt;code&amp;gt;/wp-login.php&amp;lt;/code&amp;gt; was detected within hours. Two layers of protection:&lt;br /&gt;
&lt;br /&gt;
=== Layer 1 — Hide the login URL ===&lt;br /&gt;
&lt;br /&gt;
Install the &amp;#039;&amp;#039;&amp;#039;WPS Hide Login&amp;#039;&amp;#039;&amp;#039; plugin in WordPress:&lt;br /&gt;
&lt;br /&gt;
# WordPress admin → Plugins → Add New → search &amp;lt;code&amp;gt;WPS Hide Login&amp;lt;/code&amp;gt;&lt;br /&gt;
# Install and activate&lt;br /&gt;
# Settings → WPS Hide Login → set a custom login path (e.g. &amp;lt;code&amp;gt;/your-secret-path&amp;lt;/code&amp;gt;)&lt;br /&gt;
# Save&lt;br /&gt;
&lt;br /&gt;
The default &amp;lt;code&amp;gt;/wp-login.php&amp;lt;/code&amp;gt; now returns 404 to everyone.&lt;br /&gt;
&lt;br /&gt;
=== Layer 2 — Restrict via NPM custom location ===&lt;br /&gt;
&lt;br /&gt;
In NPM, edit your WordPress proxy host → Custom Locations tab:&lt;br /&gt;
&lt;br /&gt;
Add location: &amp;lt;code&amp;gt;/wp-login.php&amp;lt;/code&amp;gt;&lt;br /&gt;
* Forward Hostname/IP: &amp;lt;code&amp;gt;&amp;amp;lt;wordpress-server-IP&amp;amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* Forward Port: &amp;lt;code&amp;gt;&amp;amp;lt;wordpress-port&amp;amp;gt;&amp;lt;/code&amp;gt;&lt;br /&gt;
* In the gear/config box, add:&lt;br /&gt;
&lt;br /&gt;
&amp;lt;syntaxhighlight lang=&amp;quot;nginx&amp;quot;&amp;gt;&lt;br /&gt;
allow 192.168.196.0/24;&lt;br /&gt;
deny all;&lt;br /&gt;
&amp;lt;/syntaxhighlight&amp;gt;&lt;br /&gt;
&lt;br /&gt;
Repeat for &amp;lt;code&amp;gt;/wp-admin/&amp;lt;/code&amp;gt;.&lt;br /&gt;
&lt;br /&gt;
This means even if someone discovers your hidden login URL, they cannot reach it unless they are on your ZeroTier network.&lt;br /&gt;
&lt;br /&gt;
----&lt;br /&gt;
&lt;br /&gt;
== Key Takeaways ==&lt;br /&gt;
&lt;br /&gt;
&amp;#039;&amp;#039;&amp;#039;On security:&amp;#039;&amp;#039;&amp;#039;&lt;br /&gt;
* Never follow tutorials directly on a bare server. Use Distrobox or Docker to isolate experiments.&lt;br /&gt;
* Check for open mail relays — an open Postfix is a common infection consequence.&lt;br /&gt;
* Bots scan for &amp;lt;code&amp;gt;/wp-login.php&amp;lt;/code&amp;gt; within hours of a site going live. Hide it and restrict it immediately.&lt;br /&gt;
&lt;br /&gt;
&amp;#039;&amp;#039;&amp;#039;On operations:&amp;#039;&amp;#039;&amp;#039;&lt;br /&gt;
* NPM is stateless enough to be fully backed up in a single tar file and restored in under 15 minutes.&lt;br /&gt;
* ZeroTier makes home servers first-class infrastructure — a cheap VPS becomes just a public IP rental.&lt;br /&gt;
* Always run &amp;lt;code&amp;gt;docker ps&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;ss -tlnp&amp;lt;/code&amp;gt; before and after a migration to confirm port ownership.&lt;br /&gt;
* When restoring, mind whether you are logged in as root or a regular user — file paths differ and commands like &amp;lt;code&amp;gt;tar -C /&amp;lt;/code&amp;gt; require root.&lt;br /&gt;
&lt;br /&gt;
&amp;#039;&amp;#039;&amp;#039;On architecture:&amp;#039;&amp;#039;&amp;#039;&lt;br /&gt;
* A ₱5,000/yr VPS as a pure relay is far more resilient than a ₱15–30k/yr VPS running everything. When it gets compromised again, you reformat it in 15 minutes with nothing to lose.&lt;br /&gt;
* SSL certificate renewals continue to work transparently through the relay — Let&amp;#039;s Encrypt ACME challenges on port 80 get forwarded to your home NPM automatically.&lt;br /&gt;
&lt;br /&gt;
[[Category:System Administration]]&lt;br /&gt;
[[Category:Networking]]&lt;br /&gt;
[[Category:Docker]]&lt;br /&gt;
[[Category:Tutorials]]&lt;br /&gt;
[[Category:ZeroTier]]&lt;br /&gt;
[[Category:Security]]&lt;/div&gt;</summary>
		<author><name>Justinaquino</name></author>
	</entry>
</feed>