Most “deploy to a Droplet” guides assume a single app. Real projects rarely look like that. A common shape is three services that ship together:
| App | What it is | Runs as |
|---|---|---|
| api | Express + Prisma | a Node process managed by PM2 |
| client | Next.js storefront | a Node process managed by PM2 |
| admin | Vite React SPA | static files served straight by Nginx |
All three sit on one box. Nginx sits in front and routes by subdomain; Certbot issues the TLS certs; MySQL runs locally. This post walks the whole thing end to end and explains what every command actually does, so you’re not just pasting.
Throughout I’ll use placeholders — swap them for your own:
example.com→ the storefrontadmin.example.com→ the admin panelapi.example.com→ the API/var/www/myapp→ wherever you clone the repo
The routing picture
Before any commands, hold the end state in your head. One public IP, three subdomains all pointing at it. Nginx reads the Host header on each request and decides:
example.com→ proxy to the Next.js process on a local portapi.example.com→ proxy to the Express process on a different local portadmin.example.com→ serve static files off disk, no proxy at all
The two Node apps never face the internet directly. They listen only on 127.0.0.1, and Nginx is the single front door.
1. Create the Droplet
Ubuntu 24.04 LTS, 2 GB RAM or more. The builds — especially Next.js — are memory-hungry; a 1 GB box can run out of RAM mid-build unless you add swap (step 2).
Before you touch the server, point DNS at it. Create A records for the root domain, admin, and api, all aimed at the Droplet’s IP. Do this first — Certbot later validates each domain over HTTP, and validation fails if the name doesn’t resolve to the box yet. DNS takes time to propagate, so getting it out of the way early means it’s ready when you need it.
SSH in as root (or a sudo user) for everything below.
2. Base packages
apt update && apt upgrade -y
apt install -y curl git nginx mysql-server ufw apt updaterefreshes the list of available packages;apt upgrade -yinstalls pending updates (-yauto-confirms so it doesn’t stop to ask).- The second line installs everything the stack needs in one go:
curlandgitfor fetching,nginxas the reverse proxy,mysql-serverfor the database, andufw(Uncomplicated Firewall) to control which ports are open.
Swap (only on a 1 GB Droplet)
fallocate -l 2G /swapfile && chmod 600 /swapfile
mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab fallocate -l 2G /swapfilereserves a 2 GB file on disk;chmod 600locks its permissions so only root can read it.mkswapformats that file as swap space;swaponactivates it immediately.- The
echo … >> /etc/fstabline appends an entry to the filesystem table so the swap is re-enabled automatically after a reboot. Skip this whole block on a 2 GB+ box.
Node.js
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
apt install -y nodejs
node -v - The
curl … | bash -line downloads NodeSource’s setup script and runs it, which adds their apt repository for the Node major version you want. The flags:-ffail on server errors,-ssilent,-Sstill show errors,-Lfollow redirects. apt install -y nodejsthen installs Node from that repo.node -vprints the installed version so you can confirm it’s what you expected before moving on.
PM2
npm install -g pm2 PM2 is a process manager for Node. It keeps the api and client processes running, restarts them if they crash, and (once configured) brings them back after a reboot. -g installs it globally so the pm2 command is available everywhere.
Firewall
ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw enable ufw allow OpenSSHkeeps port 22 open — do this before enabling the firewall or you can lock yourself out of your own server.ufw allow 'Nginx Full'opens ports 80 and 443 (HTTP and HTTPS) — that’s theNginx Fullprofile.ufw enableturns the firewall on. Everything not explicitly allowed is now blocked, including direct access to the app ports — exactly what we want.
3. MySQL
mysql_secure_installation
mysql -u root -p mysql_secure_installation is an interactive script that removes anonymous accounts, disables remote root login, and lets you set a root password — run it once on any fresh install. The second line opens a SQL shell as root (-p prompts for the password).
Inside the shell, create a database and a dedicated user for the app:
CREATE DATABASE myapp CHARACTER SET utf8mb4;
CREATE USER 'myapp_user'@'localhost' IDENTIFIED BY 'REPLACE_WITH_A_STRONG_PASSWORD';
GRANT ALL PRIVILEGES ON myapp.* TO 'myapp_user'@'localhost';
FLUSH PRIVILEGES;
EXIT; CREATE DATABASE … CHARACTER SET utf8mb4makes the database with full Unicode support (utf8mb4 handles emoji and every other character correctly).CREATE USER … @'localhost'makes an app-specific account that can only connect from the same machine — the app never needs remote DB access.GRANT ALL PRIVILEGES ON myapp.*gives that user full rights on this one database (and nothing else).FLUSH PRIVILEGESreloads the grant tables so the changes take effect.
Give the app its own user rather than reusing root — if the app is ever compromised, the blast radius is one database.
4. Clone the repo
mkdir -p /var/www && cd /var/www
git clone https://github.com/your-org/myapp.git
cd myapp mkdir -p /var/wwwcreates the directory (-pmeans “don’t error if it already exists”), thencdmoves into it.git clonedownloads the repo; the finalcd myappsteps into it./var/wwwis the conventional home for web app code on a Linux server.
Cloning over SSH instead of HTTPS
The HTTPS URL above works for any public repo. For a private repo — or if you’d rather not paste a personal access token every time you pull — clone over SSH instead. First generate a key on the Droplet and register its public half with GitHub:
ssh-keygen -t ed25519 -C "deploy@myapp" # press Enter through the prompts
cat ~/.ssh/id_ed25519.pub # copy this line ssh-keygen -t ed25519generates a modern, compact key pair;-Cjust adds a label so you can recognise it later. Accept the default path and leave the passphrase empty if this key is only for unattended deploys.cat …/id_ed25519.pubprints the public key. Add it to GitHub under Settings → SSH and GPG keys → New SSH key (or, for a single repo, as a read-only deploy key under the repo’s Settings → Deploy keys). Never share the matching private key (id_ed25519, no.pub) — it stays on the Droplet.
Verify the connection, then clone using the SSH URL:
ssh -T git@github.com # confirms auth; a greeting means success
git clone git@github.com:your-org/myapp.git ssh -T git@github.comopens a test connection. On first run it asks you to trust GitHub’s host key (typeyes); aHi your-org! You've successfully authenticatedmessage means the key is wired up.- The
git@github.com:your-org/myapp.gitform is the SSH remote — same repo, no password prompts on futuregit pulls during redeploys.
5. Configure the environment files
Each app reads its own .env. Copy the checked-in examples and fill in the blanks:
cp api/example.env api/.env
cp client/.env.example client/.env
cp admin/.env.example admin/.env Rather than list secrets, here’s the shape of what each needs — real values stay out of git.
api/.env — the server’s config. Typical variables:
PORT= # local port the API listens on
NODE_ENV=production
CLIENT_URL= # public URL of the storefront
CORS_ORIGINS= # comma-separated list of allowed front-end origins
DATABASE_URL= # mysql://myapp_user:PASSWORD@localhost:3306/myapp
JWT_SECRET= # long random string for signing tokens
MAIL_API_KEY= # transactional email provider key
PAYMENT_API_KEY= # payment gateway key client/.env — the Next.js front end. Only variables prefixed for public exposure end up in the browser bundle:
NEXT_PUBLIC_API_URL= # public URL of the API admin/.env — the Vite SPA. Vite bakes these in at build time, so they must be set before you build, and any change means a rebuild:
VITE_API_URL= # public URL of the API The pattern to internalise: NEXT_PUBLIC_* (Next.js) and VITE_* (Vite) variables are compiled into front-end code and are therefore public. Never put a secret behind those prefixes — secrets live only in api/.env, which is read at runtime on the server and never shipped to a browser.
6. Install and build everything
A root package.json wires the three apps together with helper scripts:
npm run install:all
npm run build npm run install:allinstalls dependencies inapi/,client/, andadmin/— one command instead of threecd-and-npm installcycles.npm run buildcompiles all three: the api transpiles to adist/folder, the client produces an optimized Next.js build, and the admin outputs static HTML/JS/CSS intoadmin/dist.
(If your repo doesn’t have these convenience scripts, run the install/build in each app directory by hand — same effect.)
7. Database schema and seed
cd api
npx prisma migrate deploy
npm run db:seed
cd .. npx prisma migrate deployapplies all pending migrations to the database. This is the production migration command — it runs existing migrations without prompting and never tries to generate new ones. (Its cousinmigrate devis for local development only; don’t run it on a server.)npm run db:seedpopulates initial data — typically the first admin account and any reference tables. Set the seed admin credentials via env vars beforehand, or change the password right after first login.
8. Start the Node apps with PM2
pm2 start api/dist/server.js --name myapp-api --cwd api
pm2 start npm --name myapp-client --cwd client -- start
pm2 save
pm2 startup - The first line starts the compiled API.
--namelabels the process so you can refer to it later;--cwd apiruns it with theapi/directory as its working directory so it finds its.envand relative paths. - The second line starts the client. Because Next.js is launched via an npm script, the target is
npmand everything after--(here,start) is passed through as the script name — so this runsnpm startinsideclient/. pm2 savesnapshots the current process list so it can be restored later.pm2 startupprints a command that registers PM2 as a system service — run the command it prints, then the boot-time restore works. Together withpm2 save, this is what brings both apps back automatically after a reboot.
Day-to-day commands:
pm2 status # see all processes at a glance
pm2 logs myapp-api # tail one app's logs
pm2 restart myapp-api myapp-client Note the admin app isn’t here — it’s static files, so there’s no process to manage.
9. Nginx: one config, three server blocks
This is where the routing happens. One config file holds three server blocks — one per subdomain. Create /etc/nginx/sites-available/myapp:
# Storefront (Next.js) — proxied to the local client process
server {
listen 80;
server_name example.com www.example.com;
location / {
proxy_pass http://127.0.0.1:4002;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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;
proxy_cache_bypass $http_upgrade;
}
}
# Admin (Vite SPA) — static files served straight off disk
server {
listen 80;
server_name admin.example.com;
root /var/www/myapp/admin/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
# API (Express) — proxied to the local api process
server {
listen 80;
server_name api.example.com;
client_max_body_size 20m; # allow file uploads
location / {
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
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;
}
} What the pieces do:
server_nameis how Nginx matches the incomingHostheader to the right block. This is the whole subdomain-routing trick.proxy_pass http://127.0.0.1:PORTforwards the request to a Node process on the loopback interface. The two apps only listen locally; Nginx is the only thing exposed.- The
proxy_set_headerlines pass along the real client info. WithoutX-Forwarded-ForandX-Forwarded-Proto, your app sees every request as coming from127.0.0.1over plain HTTP and can’t tell it’s actually behind HTTPS. Upgrade/Connection 'upgrade'on the client block let WebSocket connections pass through — Next.js needs this for hot features and live updates.- The admin block has no proxy.
rootpoints at the built files andtry_files $uri $uri/ /index.htmlmeans: look for the requested file, then a matching directory, and if neither exists, fall back toindex.html. That fallback is what makes client-side routing work — refreshing/admin/settingsserves the SPA instead of a 404. client_max_body_size 20mon the api raises Nginx’s default 1 MB request cap so uploads aren’t rejected before they reach the app.
Enable and reload:
ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
nginx -t
systemctl reload nginx ln -screates a symlink fromsites-availableintosites-enabled— the split lets you keep a config on disk without it being live. The symlink is what activates it.nginx -ttests the config for syntax errors. Always run this before reloading; a broken config that’s already live is a bad afternoon.systemctl reload nginxapplies the new config without dropping existing connections (unlikerestart, which fully stops and starts).
10. TLS with Certbot
apt install -y certbot python3-certbot-nginx
certbot --nginx -d example.com -d www.example.com -d admin.example.com -d api.example.com - The first line installs Certbot and its Nginx plugin.
certbot --nginx -d …requests a Let’s Encrypt certificate for every domain listed. The--nginxplugin does the heavy lifting: it proves you control each domain, edits your Nginx config in place to add thelisten 443 sslblocks and an HTTP→HTTPS redirect, and reloads Nginx.
Certbot also installs a systemd timer that renews certs automatically before they expire. Confirm it’s active:
systemctl status certbot.timer This is why DNS had to be right in step 1 — Certbot validates each -d domain by hitting it over HTTP, and validation fails for any name that doesn’t resolve to this Droplet.
11. Verify
Walk the whole surface:
https://example.com— storefront loadshttps://admin.example.com— admin login page loadshttps://api.example.com/health— returns200- Log into admin with the seeded credentials
If a subdomain 502s, the Node process behind it isn’t up — check pm2 status and pm2 logs. If it can’t be reached at all, re-check DNS and the server_name in the Nginx block.
12. Redeploying after a code change
The steady-state loop:
cd /var/www/myapp
git pull
npm run install:all
npm run build
cd api && npx prisma migrate deploy && cd ..
pm2 restart myapp-api myapp-client git pullfetches and merges the new code.install:allpicks up any new dependencies;buildrecompiles all three apps.prisma migrate deployapplies any migrations that came with the release.pm2 restartreloads the two Node apps with the new build.
The admin app needs no restart — Nginx serves whatever is in admin/dist on the next request, and build just overwrote it. That’s the quiet upside of the static-SPA approach: the front end deploys the instant the files change.
Things worth remembering
- Build memory. The Next.js build is the usual OOM culprit on small Droplets. Swap (step 2) or a bigger plan fixes it.
- Front-end env vars are baked at build time. Change a
NEXT_PUBLIC_*orVITE_*value and you must rebuild — restarting the process does nothing, because the value is already compiled into the bundle. - Locally-uploaded files live on the Droplet. If the API writes uploads to a folder on disk, that folder isn’t in git and won’t survive a rebuild of the box. Back it up, or push uploads to object storage.
- Rotate secrets before going live. Every
REPLACE_WITH_*placeholder — DB password,JWT_SECRET, API keys — should be a real, unique value, not a copied example. - Test Nginx before every reload.
nginx -tcosts a second and saves you from taking all three sites down with a stray brace.
That’s the full path: one bare Ubuntu box to three HTTPS-secured apps — an API, a server-rendered storefront, and a static admin panel — all routed by subdomain from a single Nginx front door.