Deploying a Node.js App on Ubuntu
Getting a Node.js app onto a real server is the moment your code stops being a project on your laptop and starts being something other people can actually use. This page walks you through the full path on a fresh Ubuntu server: installing Node, pulling down your code, installing dependencies cleanly, setting your environment variables, and running the app. We will do every step by hand so you understand what each piece does, then point you to the pages that turn this into a proper, always-on production setup.
This guide targets Ubuntu 22.04 LTS and 24.04 LTS. Every command is real and runs on a fresh server.
Before you start
You need a server you can log into over SSH (Secure Shell — an encrypted way to run commands on a remote machine) and a user with sudo (the command that lets a normal user run admin-level commands). If you only have the root user, that works too, but running apps as root is a security risk, so we will assume a regular user named deploy.
First update the package lists and install Git and a build toolchain. Many npm packages compile native code, and build-essential provides the compilers they need.
sudo apt update
sudo apt install -y git build-essential
Output:
Reading package lists... Done
Building dependency tree... Done
git is already the newest version (1:2.43.0-1ubuntu7.1).
build-essential set up (12.10ubuntu1).
0 upgraded, 0 newly installed.
Installing Node.js
There are two good ways to install Node on Ubuntu. Pick one.
| Method | What it is | When to use it |
|---|---|---|
| nvm | Node Version Manager — installs Node per-user in your home folder, lets you switch versions instantly | You want to run different Node versions, or test upgrades safely. Best for most developers. |
| NodeSource | An official apt repository that installs Node system-wide | You want one fixed version managed like any other system package, installed for all users and services. |
Do not use the plain apt install nodejs from Ubuntu’s default repos — it is often years out of date.
Option A: nvm (recommended for flexibility)
nvm (Node Version Manager) installs Node inside your home directory, so you never need sudo to change versions.
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install --lts
nvm use --lts
node -v
Output:
Now using node v22.16.0 (npm v10.9.2)
v22.16.0
Gotcha: nvm only loads inside an interactive shell that reads
~/.bashrc. Asystemdservice or a cron job does not get nvm’s Node on itsPATH. If you plan to run your app under systemd (covered on the systemd page), use NodeSource instead, or point the service at the full Node path like/home/deploy/.nvm/versions/node/v22.16.0/bin/node.
Option B: NodeSource (recommended for servers)
This adds an official Node apt repository and installs Node for the whole machine, which is cleaner for background services.
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
node -v
npm -v
Output:
node -v
v22.16.0
npm -v
10.9.2
Getting your code onto the server
Clone your repository with Git. For a private repo you will need a deploy key or an access token; for a public repo the HTTPS URL just works.
cd ~
git clone https://github.com/your-org/your-app.git
cd your-app
Output:
Cloning into 'your-app'...
remote: Enumerating objects: 482, done.
Receiving objects: 100% (482/482), 1.21 MiB | 4.30 MiB/s, done.
Resolving deltas: 100% (210/210), done.
Tip: Clone into a normal user’s home directory (here
/home/deploy), not somewhere owned byroot. Your app should run as an unprivileged user so a bug or exploit cannot take over the whole machine.
Installing dependencies the production way
On a server, always use npm ci instead of npm install. The ci stands for “clean install”: it deletes any existing node_modules, then installs the exact versions pinned in your package-lock.json. This makes deploys reproducible — you get the same bytes every time — and it fails loudly if the lockfile and package.json disagree, which catches mistakes early.
npm ci --omit=dev
The --omit=dev flag skips devDependencies (test tools, linters, build-only packages) because you do not need them in production. If your app needs a build step (TypeScript, a bundler), run npm ci without that flag first, build, then prune.
Output:
added 214 packages, and audited 215 packages in 6s
38 packages are looking for funding
found 0 vulnerabilities
Setting environment variables
Environment variables are key-value settings the operating system hands to your program when it starts. They are how you keep secrets (database passwords, API keys) and per-environment settings out of your code. The single most important one in production is NODE_ENV.
Setting NODE_ENV=production tells Node and most libraries (Express in particular) to run in fast, secure mode: caching templates, hiding detailed error stacks from users, and skipping development-only checks. Always set it.
The simplest approach for now is a .env file that your app reads at startup (most apps use the dotenv package, or Node’s built-in --env-file flag in Node 20+).
nano .env
NODE_ENV=production
PORT=3000
DATABASE_URL=postgres://appuser:s3cret@localhost:5432/appdb
SESSION_SECRET=change-this-to-a-long-random-string
Lock the file down so other users on the server cannot read your secrets:
chmod 600 .env
Never commit .env to Git. Add it to .gitignore. For a deeper look at managing config across environments, see the environment config page linked below.
Running the app
Now start it. With Node 20+ you can load the env file directly without any extra package:
node --env-file=.env server.js
Output:
Server listening on http://0.0.0.0:3000 (production)
Connected to database appdb
Test it from the server itself in a second SSH session:
curl -i http://localhost:3000/health
Output:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 15
{"status":"ok"}
If you let the firewall through and visit the server’s IP on port 3000, you would see the app — but do not expose Node directly to the internet. The next step is to put a reverse proxy (a server that sits in front of your app and forwards requests to it, usually Nginx) on ports 80 and 443, and keep Node listening only on localhost. Open only the ports you actually need with ufw (Uncomplicated Firewall):
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
What’s missing for real production
Running node server.js in your terminal works for a quick test, but the moment you close SSH the app dies. And if it crashes, nothing restarts it. Production needs two more things:
- A process manager so the app starts on boot and restarts on crash — either PM2 or a
systemdservice. - A reverse proxy (Nginx) in front for HTTPS, a friendly domain, and to keep Node off the public internet.
Both have dedicated pages below. Wire those up next.
Best Practices
- Always set
NODE_ENV=production— it is the single biggest free performance and security win for Node apps. - Use
npm ci, notnpm install, on servers so deploys are reproducible from the lockfile. - Run the app as an unprivileged user (e.g.
deploy), never asroot. - Keep secrets in a
.envfile withchmod 600permissions, and never commit it to Git. - Bind Node to
localhostand put Nginx in front; only open ports 22, 80, and 443 inufw. - Pin a specific Node LTS version and document it, so the next deploy uses the same runtime.
- Add a
/healthendpoint so your proxy and monitoring can check the app is alive.