Server Setups/Getting started
Provision an Ubuntu Node server from scratch
From a bare Ubuntu 24.04 box to a hardened host running Node under systemd, in about twenty minutes.
On this page
This is the build I run for every small Node service: a single Ubuntu 24.04 host, no containers, no orchestrator. It is deliberately unfashionable. It is also the setup I have had to debug least.
By the end you will have a non-root deploy user, key-only SSH, a firewall, and an application running under systemd that restarts on failure and survives a reboot.
Before you start
- A fresh Ubuntu 24.04 LTS host with a public IP.
- An SSH keypair on your machine (
ssh-keygen -t ed25519if you need one). - A DNS A record pointing at the box, if you plan to add TLS afterwards.
Build the host
Step 01
Create a non-root deploy user
Working as root is the single most common cause of an unrecoverable box. Create the user first, before anything else is installed.
bash adduser --disabled-password --gecos "" deploy usermod -aG sudo deploy rsync --archive --chown=deploy:deploy ~/.ssh /home/deployStep 02
Lock down SSH
Disable password authentication and root login. Keep your current session open until you have verified the new one works — locking yourself out here means rebuilding the host.
/etc/ssh/sshd_config.d/99-hardening.confbash PermitRootLogin no PasswordAuthentication no KbdInteractiveAuthentication noThen reload and test from a second terminal:
bash sudo systemctl reload ssh ssh deploy@your-hostStep 03
Enable the firewall
Allow SSH and the two web ports, deny everything else. Do this before the application starts listening.
bash sudo ufw default deny incoming sudo ufw default allow outgoing sudo ufw allow OpenSSH sudo ufw allow 80,443/tcp sudo ufw enableStep 04
Install Node without a version manager
On a server, a version manager is an extra failure mode between systemd and your binary. Install a pinned major from NodeSource and upgrade deliberately.
bash curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - sudo apt-get install -y nodejs node --versionStep 05
Run the app under systemd
systemd gives you restart-on-failure, boot persistence and log aggregation for free. There is no reason to add a process manager on top of it.
Write the unit, enable it, and confirm it comes back after a reboot — that last check is the one people skip.
The systemd unit
This is the whole thing. Restart=always with a backoff, an explicit user, and the environment loaded from a file that is not in version control.
[Unit]
Description=brainNotFound web
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/home/deploy/app
EnvironmentFile=/home/deploy/app/.env
ExecStart=/usr/bin/node server.js
Restart=always
RestartSec=3
StandardOutput=journal
StandardError=journal
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.targetsudo cp app.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now app
sudo systemctl status app# follow the live log
journalctl -u app -f
# everything since the last boot
journalctl -u app -b
# just the failures
journalctl -u app -p err --since "1 hour ago"Where things live
One directory, one unit file, one environment file. If you cannot describe the layout in six lines, it is too clever.
/home/deploy
├── app/
│ ├── .env # 0600, deploy:deploy, never committed
│ ├── server.js
│ ├── node_modules/
│ └── package.json
└── .ssh/
└── authorized_keysEnvironment variables
The unit loads this file at start. Permissions matter more than the contents: chmod 600 and owned by the deploy user, or every process on the box can read your secrets.
| Variable | Description | Required |
|---|---|---|
NODE_ENVproduction | Always production on a server. Some libraries change behaviour on it. | Required |
PORT3000 | Port the app binds to. Keep it above 1024 so the process needs no privileges. | Required |
DATABASE_URLpostgres://user:pass@localhost:5432/app?sslmode=disable | Postgres connection string, including sslmode. | Required |
SENTRY_DSN | Error reporting endpoint. Omit and errors go to journald only. | Optional |
What to check before calling it done
| Check | Command | Expected |
|---|---|---|
| SSH is key-only | ssh -o PubkeyAuthentication=no deploy@host | Permission denied |
| Firewall is up | sudo ufw status | Status: active |
| Service survives reboot | sudo reboot && systemctl is-active app | active |
| Logs are readable | journalctl -u app -n 20 | Recent app output |
Once this passes, move on to the reverse proxy — the application should not be exposed directly.
// related
From the rest of the site.
A Core Web Vitals Budget You Can Actually Ship
Concrete numbers, the handful of techniques that move them, and how to stop a fast site from slowly getting slow.
