Skip to content
DevOps projects 6 min read

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).

PieceWhat runs itWhy
Frontend (static build)Nginx serves files from diskFast, no app process needed
Backend APINode.js, kept alive by systemd (Ubuntu’s service manager)Survives crashes and reboots
DatabasePostgreSQL on the same boxStores your data
HTTPS + routingNginx + CertbotOne 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, set PermitRootLogin no and PasswordAuthentication no, then run sudo 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-failure so a crash or reboot self-heals.
  • Bind the backend to localhost only; 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 dig shows 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.
Last updated June 15, 2026
Was this helpful?