You can run Node.js on DigitalOcean in two ways: on a Droplet, where you manage Ubuntu, Node, a process manager and Nginx yourself, or on App Platform, where you push a Git repo and DigitalOcean builds and runs it for you. The Droplet route costs less at the low end and gives you full control. App Platform costs a little more per container but removes the server administration entirely. This guide walks through both paths with a real Express app so you can pick the one that fits your project.
process.env.PORT.Both paths share the same Express sample app, so the first section builds that. After that the article splits: the Droplet section covers nvm, pm2, a systemd alternative, Nginx, Certbot, the firewall and zero downtime reloads. The App Platform section covers the buildpack, environment variables, scaling and logs. A troubleshooting section and an FAQ close it out.
The sample Express app
Every example below runs this minimal app. The only rule that matters for DigitalOcean is that the port comes from the environment, because App Platform assigns one at runtime and a Droplet behind Nginx should not be listening on 80 directly.
mkdir hello-node && cd hello-node
npm init -y
npm install express
cat > server.js <<'EOF'
const express = require('express');
const app = express();
const port = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.json({ status: 'ok', node: process.version, time: new Date().toISOString() });
});
app.get('/health', (req, res) => res.send('healthy'));
app.listen(port, () => console.log(`listening on ${port}`));
EOFAdd a start script and an engines field to package.json. The engines field is what App Platform reads to choose a Node version, and it also documents the version for anyone who touches the Droplet later.
{
"name": "hello-node",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"engines": {
"node": "22.x"
},
"dependencies": {
"express": "^4.19.2"
}
}Commit this to a Git repository on GitHub, GitLab or Bitbucket. App Platform needs a repo, and pulling from Git is also the cleanest way to get code onto a Droplet.
Path 1: Node.js on a Droplet
Create the Droplet and harden SSH
In the DigitalOcean control panel choose Create, then Droplets. Pick Ubuntu 24.04 LTS, the region closest to your users, and the smallest Basic plan for a test. Add your SSH key at creation time instead of using a root password. Once it boots, connect and create a regular user so you are not running Node as root.
ssh root@YOUR_DROPLET_IP
adduser deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy
apt update && apt upgrade -y
apt install -y nginx git ufwFrom here on, log in as deploy.
Install Node with nvm
Ubuntu’s apt package for Node is usually old. nvm lets you install the exact major version your app declares and switch versions later without touching system packages. The install script is published on the nvm GitHub project; check the README for the current version tag before you run it.
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash
source ~/.bashrc
nvm install 22
nvm use 22
nvm alias default 22
node -v
npm -vNow clone the app and install production dependencies only.
cd ~
git clone https://github.com/YOU/hello-node.git
cd hello-node
npm ci --omit=dev
PORT=3000 node server.js # sanity check, then Ctrl+CKeep it running with pm2
pm2 is the standard process manager for Node on a single server. It restarts the app if it crashes, runs it in cluster mode across all CPU cores and resurrects everything after a reboot.
npm install -g pm2
cat > ecosystem.config.js <<'EOF'
module.exports = {
apps: [{
name: 'hello-node',
script: 'server.js',
instances: 'max',
exec_mode: 'cluster',
env: { NODE_ENV: 'production', PORT: 3000 }
}]
};
EOF
pm2 start ecosystem.config.js
pm2 save
pm2 startup # prints a sudo command; copy and run itRun the exact sudo line that pm2 startup prints; it installs a systemd unit that restores your saved process list at boot.
Zero downtime reloads
Because the app runs in cluster mode, pm2 can restart workers one at a time so the listening socket never closes. That is your deploy command from now on.
cd ~/hello-node
git pull
npm ci --omit=dev
pm2 reload hello-node
pm2 logs hello-node --lines 50pm2 reload is graceful; pm2 restart kills every worker at once. Use reload for deploys and restart only when you change environment variables that workers read at startup.The systemd alternative
If you would rather not add pm2, a plain systemd unit does the job for a single process. It will not give you cluster mode or graceful reloads, but it is one less tool to learn.
sudo tee /etc/systemd/system/hello-node.service > /dev/null <<'EOF'
[Unit]
Description=hello-node Express app
After=network.target
[Service]
User=deploy
WorkingDirectory=/home/deploy/hello-node
Environment=NODE_ENV=production
Environment=PORT=3000
ExecStart=/home/deploy/.nvm/versions/node/v22.11.0/bin/node server.js
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now hello-node
sudo systemctl status hello-nodeCheck the exact node path with which node after nvm use, because the version directory in ExecStart must match what nvm installed.
Nginx as a reverse proxy
Nginx terminates TLS, serves static files and forwards everything else to the Node process on port 3000. Replace the default site with this server block.
sudo tee /etc/nginx/sites-available/hello-node > /dev/null <<'EOF'
server {
listen 80;
server_name example.com www.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
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;
}
}
EOF
sudo ln -s /etc/nginx/sites-available/hello-node /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginxPoint an A record for your domain at the Droplet IP before the next step, because Certbot validates over HTTP on port 80.
HTTPS with Certbot
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/bin/certbot
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot renew --dry-runThe Nginx plugin rewrites your server block to listen on 443 and adds a redirect from 80. Renewal runs from a systemd timer that the snap installs, so there is nothing else to schedule. Details are in the official Certbot instructions.
Firewall
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw statusPort 3000 stays closed to the outside world; only Nginx on the same machine can reach it. A DigitalOcean Cloud Firewall from the Networking menu can enforce the same rules before traffic reaches the Droplet.
ufw allow OpenSSH before ufw enable. Enabling the firewall with no SSH rule will lock you out of the Droplet, and the only way back in is the recovery console.Path 2: Node.js on App Platform
Create the app
From the control panel choose Create, then App Platform. Connect your GitHub, GitLab or Bitbucket account, pick the repo and branch, and leave Autodeploy enabled so every push to that branch triggers a build. App Platform detects a Node app when it finds package.json, package-lock.json, yarn.lock or pnpm-lock.yaml in the source directory, according to the Node.js buildpack reference.
How the buildpack builds and runs your app
The buildpack installs dependencies with npm ci (or Yarn or pnpm if it finds their lockfiles), runs the build script from package.json if one is present, and then uses your start script as the run command. It reads the Node version from the engines.node field and falls back to Node 22 if you leave that out. Both dependencies and devDependencies are installed for the build, but devDependencies are pruned before deployment, so anything the app needs at runtime must be in dependencies.
You can override the detected commands on the component’s settings page under Commands, which is useful for monorepos or for a TypeScript app that needs npm run build followed by node dist/server.js.
Environment variables and secrets
Open the component, then Settings, then Environment Variables. Add keys like DATABASE_URL and tick Encrypt for anything sensitive. App Platform sets PORT itself, which is why the sample app reads it from the environment rather than hardcoding 3000. If you attach a managed database from the Add Resource menu, App Platform injects its connection string as a bindable variable such as ${db.DATABASE_URL}.
Scaling
Under the component’s Resources tab you choose an instance size and a fixed instance count, or turn on autoscaling with a minimum and maximum count and a CPU target. App Platform only routes traffic to a new revision after its health check passes, so set the health check path to /health from the sample app.
Logs and deploy history
Runtime logs stream in the Runtime Logs tab, and each deploy has its own build log. From a terminal you can tail them with doctl apps logs APP_ID --type run --follow after installing the doctl CLI. Log forwarding to an external service is available from the app settings if you need longer retention.
Droplet vs App Platform at a glance
| Concern | Droplet | App Platform |
|---|---|---|
| Setup effort | An hour the first time: SSH, nvm, pm2, Nginx, Certbot, ufw | Minutes: connect repo, confirm detected settings |
| OS patching | You | DigitalOcean |
| TLS certificates | Certbot, renewed by a timer | Automatic for custom domains |
| Deploys | git pull plus pm2 reload, or a CI job over SSH | Push to branch, rolling deploy with health check |
| Scaling | Resize the Droplet, or add a load balancer and more Droplets | Slider or autoscaling per component |
| Cost at low end | Cheapest option for one app | Slightly more per container, no idle server to pay for otherwise |
| Best for | Full control, background jobs, unusual runtimes, several apps on one box | Web APIs and sites with a standard build, small teams |
If you already run other things on a Droplet, such as a forum from our Discourse on DigitalOcean tutorial, adding a Node app behind the same Nginx is cheap. If the Node app is all you host, App Platform removes most of the maintenance. For a static frontend see where you can deploy Vue.js and our Gatsby on Vultr guide.
Troubleshooting
502 Bad Gateway from Nginx
Nginx is up but cannot reach Node. Run pm2 status or systemctl status hello-node to confirm the process is alive, then curl http://127.0.0.1:3000/ on the Droplet. If curl works and Nginx still fails, the proxy_pass port in your server block does not match the app’s PORT.
App Platform build succeeds but the app never becomes healthy
Almost always the app is listening on a hardcoded port instead of process.env.PORT, or it is binding to 127.0.0.1 rather than all interfaces. Use app.listen(port) with no host argument. Also check that the health check path returns a 200 within the timeout.
“Cannot find module” only in production
The package is in devDependencies. App Platform prunes those before deploy, and npm ci --omit=dev skips them on a Droplet. Move it to dependencies. If you truly need devDependencies at runtime on App Platform, set NPM_CONFIG_PRODUCTION=false as documented in the buildpack reference.
pm2 processes vanish after a reboot
You ran pm2 startup but not the sudo command it printed, or you added apps after pm2 save. Run the startup command, start your apps, then pm2 save again.
Certbot fails with a connection or DNS error
The A record has not propagated, or port 80 is blocked by ufw or a Cloud Firewall. Confirm dig +short example.com returns the Droplet IP and that ufw status shows Nginx Full allowed.
Frequently asked questions
Which is cheaper for a small Node.js app, a Droplet or App Platform?
The entry level Droplet is the cheapest way to host one Node app, and you can put several apps on it. App Platform’s smallest paid container costs a bit more, but you are not paying for an idle server, TLS or patching. For a single low traffic API the difference is a few dollars a month, so decide on maintenance effort rather than price.
Do I need Nginx if I use pm2?
Not strictly, but you should use it. Nginx handles TLS termination, HTTP/2, static files, gzip and request buffering far better than Express does, and it lets Node run as an unprivileged user on a high port. Exposing Node directly on port 80 or 443 means running it as root or juggling capabilities.
How do I run a TypeScript app on App Platform?
Keep typescript in devDependencies, add a build script that runs tsc, and set the start script to node dist/server.js. The buildpack runs the build script before pruning devDependencies, so the compiled JavaScript ships without the compiler. Set the build and run commands explicitly in the component settings if detection picks the wrong ones.
Can I deploy to a Droplet automatically from GitHub?
Yes. A GitHub Actions workflow with an SSH step can run git pull, npm ci --omit=dev and pm2 reload on every push to main. Store the private key as a repository secret and restrict the deploy user’s permissions. It is a few lines of YAML and gives you most of App Platform’s push to deploy flow.
The bottom line
Running Node.js on DigitalOcean comes down to one question: do you want to own the server? If yes, a Droplet with nvm, pm2, Nginx, Certbot and ufw is a proven stack that takes about an hour to set up and years to outgrow. If no, App Platform’s Node.js buildpack turns a repo with a start script and an engines field into a running, TLS secured service with rolling deploys.
Either way, write the app to read PORT from the environment, keep runtime packages in dependencies, and add a health endpoint. Those three habits make the app portable between both paths, so you can start on a cheap Droplet and move to App Platform later, or the other way around, without touching the code.

