How to Master Calligraphy

I have 2 next js app and 1 nodeexpress app - http://72.60.219.181/ this is the ip address of the vps. ufw is also setup so u need to properly allow - for now will work with ip and then move to domain afterward - use apache reverse proxy also. do not touch 000defaultconfig - make config per file. backend port will be 8879 and the frontend will be 34321, 34322. assume github links of each for now - we will share later. now create the script to do all at once
Architected multi-app deployment strategy using Apache reverse proxy routing
Architected multi-app deployment strategy using Apache reverse proxy routing
Quick assumption before I hand this over: with just an IP (no domains yet), Apache can't tell apps apart by ServerName on the same IP — so I'm exposing each app on its own external port through Apache, proxying to the internal Node ports. Layout:
App | Internal (Node/PM2) | External (Apache) |
|---|---|---|
Frontend 1 (Next.js) | 34321 | 80 (main entry) |
Frontend 2 (Next.js) | 34322 | 8082 |
Backend (Express) | 8879 | 8083 |
Node ports (34321/34322/8879) stay firewalled off from the internet — only reachable via localhost through Apache. When you get a domain, this collapses cleanly to ServerName-based vhosts on 80/443 with certbot — I've left notes for that.
I'll create the script now and read the relevant setup notes as I go.
Determined to deliver deployment script directly without embellishment
Determined to deliver deployment script directly without embellishment
Create working directory
Script
I need to write out the script directly without any additional commentary or formatting.
Create the all-in-one VPS deployment script for 2 Next.js apps + 1 Express backend behind Apache reverse proxy with UFW rules
setup-vps.sh
Validate bash syntax
Script
Copy final script to outputs
Script
Presented file
Done
Before running: edit the top CONFIG block — swap in your real GitHub repo URLs (REPO_FRONTEND1/2, REPO_BACKEND). Everything else has working defaults.
What it does, in order:
Installs Node 20 LTS, PM2, Apache, UFW, git
Opens UFW for SSH + ports
80,8082,8083,443(Node ports 34321/34322/8879 stay closed to the internet — only reachable via127.0.0.1from Apache)Enables
proxy,proxy_http,proxy_wstunnel,headers,rewriteApache modulesAdds
Listen 8082/Listen 8083toports.conf(port 80 already listens by default)Clones each repo, runs
npm ci+npm run build(frontends),npm ci(backend)Starts/restarts all three under PM2,
pm2 saves, sets up boot persistenceWrites three separate vhost files —
frontend1.conf,frontend2.conf,backend.conf— undersites-available,a2ensites them, leaves000-default.confcompletely aloneRuns
configtest, reloads Apache, does a quick curl health check on all three
Result:
http://72.60.219.181/→ Frontend 1http://72.60.219.181:8082/→ Frontend 2http://72.60.219.181:8083/→ Backend API
It's safe to re-run (won't duplicate PM2 processes, Listen lines, or Apache modules).
Two assumptions baked in you should sanity-check against your actual repos:
Each
package.jsonhas a workingstartscript (Next.js:next start, Express: readsprocess.env.PORT)Your Express entrypoint actually reads
PORTfrom env rather than hardcoding8879— if it's hardcoded that's fine too, just confirm it matches
One open question I didn't want to guess on:
Q: Does your Express backend read the port from process.env.PORT, or is 8879 hardcoded in the code? A: done in code- no need to worry about that
Good — hardcoded to 8879 in code, that's exactly what the script assumes internally, so nothing to change there. The PORT="${port}" pm2 start ... env var passed to the Express app will just be ignored (no harm), and Apache's backend.conf proxies straight to 127.0.0.1:8879 regardless.
Once you share the actual repo URLs, just drop them into the REPO_FRONTEND1 / REPO_FRONTEND2 / REPO_BACKEND variables at the top and run:
bash
chmod +x setup-vps.sh
sudo ./setup-vps.shIf any step fails partway (e.g. build error in one app), it's safe to fix and re-run — PM2 restarts existing processes instead of duplicating them, and the Apache/UFW steps are idempotent too.
why 8082 and 8083? are u opening 80 and 443 completely ? exposing the whole vps. im sharing the current ufw. and i wanted [http://72.60.219.181:](http://72.60.219.181:8082/)32321 , [http://72.60.219.181:](http://72.60.219.181:8082/)34322 for frontends.
oot@srv1060125:/var/www/cly-backend-prod# ufw status Status: active To Action From -- ------ ---- 22 LIMIT Anywhere 443 ALLOW Anywhere 9878 DENY Anywhere 3000 DENY Anywhere 3001 DENY Anywhere 3300 DENY Anywhere 6555/tcp ALLOW Anywhere 7885/tcp ALLOW Anywhere 5666/tcp ALLOW Anywhere 80 ALLOW Anywhere 28471 DENY Anywhere 28472/tcp ALLOW Anywhere 28473/tcp ALLOW Anywhere 28474/tcp ALLOW Anywhere 28475/tcp ALLOW Anywhere 22 (v6) LIMIT Anywhere (v6) 443 (v6) ALLOW Anywhere (v6) 9878 (v6) DENY Anywhere (v6) 3000 (v6) DENY Anywhere (v6) 3001 (v6) DENY Anywhere (v6) 3300 (v6) DENY Anywhere (v6) 6555/tcp (v6) ALLOW Anywhere (v6) 7885/tcp (v6) ALLOW Anywhere (v6) 5666/tcp (v6) ALLOW Anywhere (v6) 80 (v6) ALLOW Anywhere (v6) 28471 (v6) DENY Anywhere (v6) 28472/tcp (v6) ALLOW Anywhere (v6) 28473/tcp (v6) ALLOW Anywhere (v6) 28474/tcp (v6) ALLOW Anywhere (v6) 28475/tcp (v6) ALLOW Anywhere (v6) 53 ALLOW OUT Anywhere 80 ALLOW OUT Anywhere 443 ALLOW OUT Anywhere 53 (v6) ALLOW OUT Anywhere (v6) 80 (v6) ALLOW OUT Anywhere (v6) 443 (v6) ALLOW OUT Anywhere (v6)
like the custom ports are live - make the ones u are making also do the same.
Show more
Recalibrated port mapping strategy and clarified firewall exposure scope
Recalibrated port mapping strategy and clarified firewall exposure scope
Good questions — let me clear up both things and fix the port mapping to match what you actually want.
On "opening 80/443 completely exposes the whole VPS" — that's not quite how UFW works. ufw allow 80/tcp only opens port 80 specifically; every other port stays under your default-deny policy (as your own ruleset shows — you've got explicit DENY lines for 9878, 3000, 3001, 3300, 28471 sitting right next to ALLOW lines for others). Opening a port ≠ opening the box. That said — you're right that I shouldn't have touched 80/443 at all here: they're already allowed in your live config (presumably serving something else already, maybe behind an existing vhost), and I have no business re-touching rules that already exist or routing a new app through a port that might already be spoken for. I've dropped port 80 entirely from this task.
On the port scheme — you want 34321 and 34322 to be the actual public URLs, matching how your other custom ports (6555, 7885, 28472-75) work: UFW allows that exact port, direct hit. The one wrinkle: since you asked for an Apache reverse proxy in front (not raw Node exposed), Apache has to be the one bound to 34321/34322/8879 externally — Node can't also bind those same numbers, or you'll get EADDRINUSE. So Node moves to a different internal-only port, Apache takes the public number. Functionally identical to what you see with your other ports; Apache is just quietly in the middle for headers/logging/future SSL.
New mapping:
App | Public (UFW + Apache listens here) | Internal (Node/PM2, loopback only) |
|---|---|---|
Frontend 1 | 34321 | 44321 |
Frontend 2 | 34322 | 44322 |
Backend | 8879 | 18879 |
I also removed the redundant ufw allow 22/tcp and ufw --force enable calls — your SSH rule is already rate-limited (LIMIT), and re-adding a plain allow would just add a weaker duplicate rule sitting next to it. Script now only touches the 3 new ports.
Reconsidered port configuration to align with existing firewall rules
Reconsidered port configuration to align with existing firewall rules
Now update the vhost templates (all three now use the same non-default-port pattern, and internal PM2 ports changed) and the summary/health-check section:
Edited 3 files
Edited 3 files
Now the closing summary section — update to reflect no port 80/443 involvement and the new mapping:
Update closing summary to reflect new port scheme and no 80/443 touch
Update closing summary to reflect new port scheme and no 80/443 touch
Now let's verify syntax and the health-check section still references the right variables, then re-copy to outputs:
Identified port conflict and pivoted to direct firewall exposure strategy
Identified port conflict and pivoted to direct firewall exposure strategy
One more important fix — you told me the backend's port 8879 is hardcoded in the Express code, not read from an env var. That changes things: Node will bind directly to 8879 itself, which means Apache can't also bind 8879 to proxy to it — same port, same host, straight conflict.
So for the backend, I'll drop Apache out of the loop entirely and expose it the same way your other raw ports (6555, 7885 etc.) already work: Node binds 8879 directly, UFW allows 8879 straight through. No reverse proxy needed since there's nothing to remap. The two Next.js frontends keep the Apache-proxy setup since PM2 can freely pass -p <port> to next start.
Edited 3 files
Edited 3 files
Now update the Apache Listen/vhost sections to drop the backend entirely, and the PM2 start function to not force a -p port for the backend:
Edited 2 files, viewed a file
Edited 2 files, viewed a file
Now remove the backend.conf Apache vhost entirely and drop its a2ensite call:
Viewed 2 files, edited a file
Viewed 2 files, edited a file
Now update the health-check and summary sections to reflect the direct backend exposure:
Edited 2 files
Edited 2 files
Let's do a final syntax check and re-copy to outputs:
Final syntax check and ensure no stale EXT_PORT_BACKEND references remain
Final syntax check and ensure no stale EXT_PORT_BACKEND references remain
Clean — no stale references left. Copying the corrected version over:
Ran a command, read a file
Ran a command, read a file
Final scheme:
App | Public URL | How | UFW rule added |
|---|---|---|---|
Frontend 1 |
| Apache proxy → Node |
|
Frontend 2 |
| Apache proxy → Node |
|
Backend |
| Direct — Node binds |
|
Untouched: SSH (22, still LIMIT), port 80, port 443, 000-default.conf, and every other existing rule in your ufw list.
Only remaining assumption to flag: the backend rule above (open_port 8879) assumes your Express app binds to 0.0.0.0:8879 (all interfaces), not 127.0.0.1:8879. If it's bound to loopback only in code, opening the UFW port won't be enough to reach it externally — that'd need a code change, not a firewall one. Worth a quick check on your end before you run this.
Setup vps
SH
now show me the config u wrote - just give as a reply i want to see any one
Resolved to exhibit a sample configuration file
Resolved to exhibit a sample configuration file
Here's the frontend1.conf it generates (Apache reverse-proxy vhost for the first Next.js app, public port 34321 → internal Node port 44321):
apache
# frontend1.conf -- Next.js app 1 (public port 34321 -> internal 44321)
<VirtualHost *:34321>
ServerName 72.60.219.181
ProxyPreserveHost On
ProxyRequests Off
# WebSocket upgrade support (harmless if unused)
RewriteEngine On
RewriteCond %{HTTP:Upgrade} =websocket [NC]
RewriteRule /(.*) ws://127.0.0.1:44321/$1 [P,L]
ProxyPass / http://127.0.0.1:44321/
ProxyPassReverse / http://127.0.0.1:44321/
ErrorLog ${APACHE_LOG_DIR}/frontend1_error.log
CustomLog ${APACHE_LOG_DIR}/frontend1_access.log combined
</VirtualHost>frontend2.conf is identical in structure, just port 34322 → 44322, and separate log filenames. The script writes each of these to /etc/apache2/sites-available/ and enables them with a2ensite — 000-default.conf never gets touched. Backend has no vhost file at all, since it's exposed directly rather than proxied.