Skip to content
brainNotFound

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.

beginner20 min
On this page
  1. Before you start
  2. The systemd unit
  3. Where things live
  4. Environment variables
  5. What to check before calling it done

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 ed25519 if you need one).
  • A DNS A record pointing at the box, if you plan to add TLS afterwards.

Build the host

  1. 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/deploy
  2. Step 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 no

    Then reload and test from a second terminal:

    bash
    sudo systemctl reload ssh
    ssh deploy@your-host
  3. Step 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 enable
  4. Step 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 --version
  5. Step 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.

Service definition and commands
[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.target

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
/home/deploy
├── app/
│   ├── .env                 # 0600, deploy:deploy, never committed
│   ├── server.js
│   ├── node_modules/
│   └── package.json
└── .ssh/
    └── authorized_keys

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

/home/deploy/app/.env
VariableDescriptionRequired
NODE_ENVproductionAlways production on a server. Some libraries change behaviour on it.Required
PORT3000Port the app binds to. Keep it above 1024 so the process needs no privileges.Required
DATABASE_URLpostgres://user:pass@localhost:5432/app?sslmode=disablePostgres connection string, including sslmode.Required
SENTRY_DSNError reporting endpoint. Omit and errors go to journald only.Optional
/home/deploy/app/.env

What to check before calling it done

CheckCommandExpected
SSH is key-onlyssh -o PubkeyAuthentication=no deploy@hostPermission denied
Firewall is upsudo ufw statusStatus: active
Service survives rebootsudo reboot && systemctl is-active appactive
Logs are readablejournalctl -u app -n 20Recent app output
Post-build checklist

Once this passes, move on to the reverse proxy — the application should not be exposed directly.

// related

From the rest of the site.