Project: Deploy a Full-Stack App to a VPS
This is the capstone project that ties the whole DevOps course together. You will take a real full-stack app — a Node.js backend talking to a PostgreSQL database, plus a built frontend (the static HTML/CSS/JS your framework produces) — and put it live on the internet at your own HTTPS domain. We will rent a VPS (a Virtual Private Server, which is just a Linux computer you rent in a data center), lock it down, install everything it needs, run the backend as a managed service, and put Nginx (a fast web server) in front as the public door. By the end you will have a URL anyone in the world can open over a secure connection.
What you are building
The shape of almost every deployed web app is the same. Users hit a single public server. That server (Nginx) serves your frontend files directly and forwards API calls to your backend, which reads and writes a database. Nginx acting as the public front that hands work to your app is called a reverse proxy (a server that sits in front of your app and forwards requests to it).
| Piece | What runs it | Why |
|---|---|---|
| Frontend (static build) | Nginx serves files from disk | Fast, no app process needed |
| Backend API | Node.js, kept alive by systemd (Ubuntu’s service manager) | Survives crashes and reboots |
| Database | PostgreSQL on the same box | Stores your data |
| HTTPS + routing | Nginx + Certbot | One secure public entry point |
Step 1 — Provision and harden the VPS
Create an Ubuntu 24.04 LTS server with any provider (DigitalOcean, Hetzner, Linode, AWS Lightsail). LTS means “Long Term Support” — it gets security updates for years, which is exactly what a server needs. Pick the smallest plan with at least 1 GB RAM. The provider gives you an IP address; SSH in as root the first time.
Never run a public server as root day to day. Create a normal user with sudo (the command that grants temporary admin rights), then disable password logins entirely.
# As root on the new server
adduser deploy
usermod -aG sudo deploy
# Copy your SSH key to the new user so you can log in as them
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy/
# Update everything
apt update && apt upgrade -y
Now turn on the UFW firewall (Uncomplicated Firewall, Ubuntu’s simple firewall front-end). Allow only SSH and web traffic; block everything else.
ufw allow OpenSSH
ufw allow 'Nginx Full' # opens ports 80 (HTTP) and 443 (HTTPS)
ufw enable
ufw status
Output:
Status: active
To Action From
-- ------ ----
OpenSSH ALLOW Anywhere
Nginx Full ALLOW Anywhere
Disable root SSH and password logins. Edit
/etc/ssh/sshd_config, setPermitRootLogin noandPasswordAuthentication no, then runsudo systemctl restart ssh. Keep your current session open and test a new login in a second terminal first — locking yourself out is the classic mistake.
When to use a VPS: when you want full control, predictable cost, and to learn the whole stack. When not to: if you never want to patch a Linux box, a managed platform (Render, Railway, Vercel) hides all of this for a higher price.
Step 2 — Install Node.js and PostgreSQL
Log back in as deploy. Install Node from NodeSource so you get a current LTS release rather than Ubuntu’s older packaged one.
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
node --version
sudo apt install -y postgresql postgresql-contrib
sudo systemctl enable --now postgresql
Output:
v22.16.0
Create a database and a dedicated database user for the app. PostgreSQL config and data live under /etc/postgresql and /var/lib/postgresql.
sudo -u postgres psql -c "CREATE USER appuser WITH PASSWORD 'change-me-strong';"
sudo -u postgres psql -c "CREATE DATABASE appdb OWNER appuser;"
Step 3 — Get your code onto the server
Clone your repository, install dependencies, and build the frontend. Put the project under /var/www, the conventional home for web content.
sudo mkdir -p /var/www/myapp && sudo chown deploy:deploy /var/www/myapp
git clone https://github.com/you/myapp.git /var/www/myapp
cd /var/www/myapp
# Backend
cd backend && npm ci
# Frontend — produces a static build (e.g. dist/ or build/)
cd ../frontend && npm ci && npm run build
Create the backend’s environment file. Keep secrets out of git; this file holds the real database password and is readable only by your user.
# /var/www/myapp/backend/.env
DATABASE_URL=postgres://appuser:change-me-strong@localhost:5432/appdb
PORT=3000
NODE_ENV=production
Step 4 — Run the backend as a systemd service
Running npm start in your terminal stops the moment you log out. systemd keeps the backend running forever, restarts it if it crashes, and starts it on boot. Create a unit file (the config that describes a service).
# /etc/systemd/system/myapp.service
[Unit]
Description=MyApp Node backend
After=network.target postgresql.service
[Service]
WorkingDirectory=/var/www/myapp/backend
EnvironmentFile=/var/www/myapp/backend/.env
ExecStart=/usr/bin/node server.js
Restart=on-failure
User=deploy
[Install]
WantedBy=multi-user.target
Enable and start it, then confirm it is listening on port 3000.
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp
curl -s http://localhost:3000/api/health
Output:
● myapp.service - MyApp Node backend
Active: active (running) since Mon 2026-06-15 10:02:11 UTC; 5s ago
{"status":"ok"}
Logs go to the journal (systemd’s central log store). View them with journalctl -u myapp -f.
Step 5 — Put Nginx in front
Nginx serves the frontend build straight from disk and forwards /api/ requests to the Node backend on localhost:3000. Create a server block (Nginx’s term for a site config) in /etc/nginx/sites-available.
# /etc/nginx/sites-available/myapp
server {
listen 80;
server_name example.com www.example.com;
# Frontend static files
root /var/www/myapp/frontend/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html; # SPA fallback
}
# API requests proxied to the Node backend
location /api/ {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Enable the site by symlinking it into sites-enabled, test the config, and reload.
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
Output:
nginx: configuration file /etc/nginx/nginx.conf test is successful
Step 6 — Point your domain
In your domain registrar’s DNS settings, create an A record (the record that maps a name to a server’s IP address) for example.com pointing at your VPS IP, and another for www. DNS can take a few minutes to propagate.
dig +short example.com
Output:
203.0.113.42
Once that prints your server’s IP, open http://example.com in a browser — your app loads, served over plain HTTP.
Step 7 — Add HTTPS with Let’s Encrypt
The last step is encryption. Certbot gets a free certificate from Let’s Encrypt and rewrites your Nginx config to use it.
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/bin/certbot
sudo certbot --nginx -d example.com -d www.example.com \
--redirect --agree-tos -m [email protected] --no-eff-email
Output:
Successfully received certificate.
Congratulations! You have successfully enabled HTTPS on https://example.com
Certbot also installs a renewal timer, so the 90-day certificates renew themselves. Visit https://example.com and you should see the padlock. Your full-stack app is live.
Best Practices
- Harden first: non-root user, key-only SSH, and UFW before you deploy anything public.
- Never commit secrets — keep the database password in the systemd
EnvironmentFile, not in git. - Let systemd own the backend with
Restart=on-failureso a crash or reboot self-heals. - Bind the backend to
localhostonly; Nginx is the single public entry point. - Build the frontend to static files and let Nginx serve them — it is far faster than proxying every asset through Node.
- Confirm
digshows your IP before running Certbot; a wrong A record is the top cause of failed HTTPS setup. - Once live, set up automated backups and basic monitoring so a problem wakes you before your users notice.