letterpad.io deploy 0/10

Deployment guideOne Ubuntu server, zero babysittingsystemd, nginx, journald, ufw

NestJS
on systemd

Take letterpad.io, a NestJS API backed by PostgreSQL, Redis and RabbitMQ, and run it so it restarts when it crashes, starts when the server boots, sits behind nginx with HTTPS, keeps a month of logs and nothing more, and exposes only three ports. Every command is here, in the order you run it.

NestJSPostgreSQLRedisRabbitMQnginxUbuntu 24.04
01

Prepare the server

A fresh Ubuntu 24.04 LTS box, patched, with a login user for you and a locked system user for the app.

Ubuntu 24.04UsersSSH
The whole server

Swipe sideways to see the whole diagram

Internetufwone Ubuntu 24.04 serversshd :22rate limitednginx80 and 443, TLSNestJS127.0.0.1:3000kept up by systemdPostgres:5432Redis:6379RabbitMQ:5672localhost only5432, 6379, 5672: droppedufw default deny incoming
  1. 1ufw lets in exactly three doors: SSH on 22, rate limited, and 80 and 443 for nginx.
  2. 2nginx handles HTTPS and passes each request to the app on 127.0.0.1:3000, which systemd keeps running.
  3. 3PostgreSQL, Redis and RabbitMQ listen on localhost only, and the firewall drops their ports anyway.

Everything in this guide runs on one server. SSH in as root or your cloud user once, then do the rest as a normal user with sudo. We use two accounts on purpose: deploy is the human who logs in and ships code, letterpad is the system user the app runs as. The app user cannot log in and owns nothing it does not need.

Patch the box and set the clock

Update packages first, then pin the timezone so log timestamps match your team. Unattended upgrades keep security patches flowing without you.

setup.shBASH
sudo apt update && sudo apt full-upgrade -y
sudo apt install -y curl git build-essential ca-certificates unattended-upgrades
sudo timedatectl set-timezone Asia/Kolkata
sudo dpkg-reconfigure -plow unattended-upgrades
TerminalOutput
$ timedatectl | grep "Time zone"
                Time zone: Asia/Kolkata (IST, +0530)

Create the two users

deploy gets a home, a shell and sudo. letterpad is a --system account with no home and nologin as its shell, so even a leaked password opens nothing.

users.shBASH
# You: a normal login user with sudo
sudo adduser deploy
sudo usermod -aG sudo deploy
sudo mkdir -p /home/deploy/.ssh
sudo cp ~/.ssh/authorized_keys /home/deploy/.ssh/
sudo chown -R deploy:deploy /home/deploy/.ssh && sudo chmod 700 /home/deploy/.ssh

# The app: a system user that can never log in
sudo useradd --system --no-create-home --shell /usr/sbin/nologin letterpad
TerminalOutput
$ id letterpad
uid=998(letterpad) gid=998(letterpad) groups=998(letterpad)

Open a second terminal and confirm ssh deploy@your-server works before you close the root session. From here on, every command runs as deploy.

02

Install the runtimes

Node.js for the app, plus the three services letterpad.io talks to: PostgreSQL, Redis and RabbitMQ.

Node 24 LTSPostgreSQLRedisRabbitMQ

Ubuntu ships an old Node, so take the current LTS from NodeSource. The three data services come straight from Ubuntu's own repository, and each one installs as a systemd service that starts on boot by itself.

Node.js

node.shBASH
curl -fsSL https://deb.nodesource.com/setup_24.x -o /tmp/nodesource_setup.sh
sudo -E bash /tmp/nodesource_setup.sh
sudo apt install -y nodejs
TerminalOutput
$ which node && node -v
/usr/bin/node
v24.x.x

Note the path /usr/bin/node. The systemd unit in module 05 calls Node by its full path, because services do not read your shell PATH.

PostgreSQL, Redis and RabbitMQ

services.shBASH
sudo apt install -y postgresql redis-server rabbitmq-server
sudo systemctl enable --now postgresql redis-server rabbitmq-server
TerminalOutput
$ systemctl list-units --type=service "postgresql*" redis-server rabbitmq-server
  UNIT                       LOAD   ACTIVE SUB     DESCRIPTION
  postgresql.service         loaded active exited  PostgreSQL RDBMS
  postgresql@16-main.service loaded active running PostgreSQL Cluster 16-main
  rabbitmq-server.service    loaded active running RabbitMQ Messaging Server
  redis-server.service       loaded active running Advanced key-value store

Two PostgreSQL units show up. postgresql.service is an umbrella that only exits; the database itself is postgresql@16-main.service. Remember that name, the app unit waits on it.

ServiceUnit namePortConfig file
PostgreSQL 16postgresql@16-main5432/etc/postgresql/16/main/
Redis 7redis-server6379/etc/redis/redis.conf
RabbitMQrabbitmq-server5672/etc/rabbitmq/rabbitmq.conf
letterpad.ioletterpad3000/etc/letterpad/letterpad.env
03

Lock down the data services

One database, one Redis password, one RabbitMQ vhost. All three listen on 127.0.0.1 only.

Least privilegeLocalhost onlyStrong passwords

Each service gets its own credentials for letterpad.io and nothing else. Generate passwords with openssl rand -hex 24 so they contain no characters that need URL escaping later.

PostgreSQL: a role and a database

The role owns its database and nothing more. PostgreSQL on Ubuntu listens on localhost by default, so there is nothing to change in postgresql.conf.

postgres.shBASH
sudo -u postgres psql -c "CREATE ROLE letterpad WITH LOGIN PASSWORD 'CHANGE_ME_PG';"
sudo -u postgres createdb --owner=letterpad letterpad
TerminalOutput
$ psql "postgresql://letterpad:CHANGE_ME_PG@127.0.0.1:5432/letterpad" -c "select 1;"
 ?column? 
----------
        1
(1 row)

Redis: bind, password, memory policy

Open /etc/redis/redis.conf with sudo nano and set these lines. Each one already exists in the file, so search for it and edit in place.

/etc/redis/redis.confCONF
bind 127.0.0.1 -::1
protected-mode yes
supervised systemd
requirepass CHANGE_ME_REDIS
maxmemory 256mb
maxmemory-policy noeviction
LineWhat it does
bind 127.0.0.1 -::1Accept connections from this machine only, over IPv4 and IPv6.
protected-mode yesA second guard: refuses outside clients even if bind is loosened later.
supervised systemdRedis tells systemd when it is ready, so units ordered after it start at the right moment.
requirepassEvery client must send this password before any command.
maxmemory 256mbCaps memory so Redis cannot starve Node or PostgreSQL.
noevictionWhen full, writes fail loudly instead of silently dropping queue keys. Use allkeys-lru if Redis is a pure cache.
redis.shBASH
sudo systemctl restart redis-server
REDISCLI_AUTH=CHANGE_ME_REDIS redis-cli ping
TerminalOutput
$ REDISCLI_AUTH=CHANGE_ME_REDIS redis-cli ping
PONG

RabbitMQ: a vhost, a user, no guest

A vhost is RabbitMQ's namespace, like a database in PostgreSQL. Give letterpad.io its own, then delete the default guest account.

rabbitmq.shBASH
sudo rabbitmqctl add_vhost letterpad
sudo rabbitmqctl add_user letterpad 'CHANGE_ME_MQ'
sudo rabbitmqctl set_permissions -p letterpad letterpad ".*" ".*" ".*"
sudo rabbitmqctl delete_user guest
TerminalOutput
$ sudo rabbitmqctl list_users
Listing users ...
user       tags
letterpad  []

Then pin the AMQP listener to localhost in /etc/rabbitmq/rabbitmq.conf (create the file if it is missing) and restart.

/etc/rabbitmq/rabbitmq.confCONF
listeners.tcp.default = 127.0.0.1:5672

Why it matters: RabbitMQ also opens ports 4369 (epmd) and 25672 (clustering) on every interface. You cannot bind those away easily on a single node, which is one more reason the firewall in module 09 denies all incoming traffic by default.

TerminalOutput
$ sudo systemctl restart rabbitmq-server && sudo ss -ltnp | grep 5672
LISTEN 0  128  127.0.0.1:5672  0.0.0.0:*  users:(("beam.smp",pid=1312,fd=35))
04

Build letterpad.io

Clone the NestJS app, keep secrets in one root-owned env file, and make the app shut down cleanly.

NestJSEnv fileGraceful shutdown

Two small changes in the app first

systemd stops a service by sending SIGTERM. NestJS ignores it unless you turn on shutdown hooks, and then it closes your PostgreSQL pool, Redis client and RabbitMQ channel before exiting. Listen on 127.0.0.1 so the only way in is through nginx.

src/main.tsTS
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.enableShutdownHooks();                       // run onModuleDestroy on SIGTERM
  app.getHttpAdapter().getInstance().set('trust proxy', 'loopback'); // real client IP from nginx
  await app.listen(Number(process.env.PORT ?? 3000), process.env.HOST ?? '127.0.0.1');
}
bootstrap();

Add a tiny health route. nginx, the deploy script and any uptime monitor will all call it. For real dependency checks, @nestjs/terminus can ping PostgreSQL, Redis and RabbitMQ from the same route.

src/health.controller.tsTS
import { Controller, Get } from '@nestjs/common';

@Controller('health')
export class HealthController {
  @Get()
  check() {
    return { status: 'ok', uptime: Math.round(process.uptime()) };
  }
}

Clone and build on the server

The code lives in /srv/letterpad, owned by deploy and readable by the letterpad group. Adding deploy to that group lets your deploy script read the env file later.

build.shBASH
sudo mkdir -p /srv/letterpad
sudo chown deploy:letterpad /srv/letterpad
sudo usermod -aG letterpad,systemd-journal deploy   # log out and back in after this

git clone git@github.com:your-org/letterpad.io.git /srv/letterpad
cd /srv/letterpad
npm ci
npm run build
npm prune --omit=dev
TerminalOutput
$ ls /srv/letterpad/dist/main.js
/srv/letterpad/dist/main.js

Secrets in one env file

systemd reads this file with EnvironmentFile=. One KEY=value per line, no export, no quotes needed. It is owned by root and readable by the letterpad group, so the app can read it and nobody else can.

/etc/letterpad/letterpad.envENV
NODE_ENV=production
HOST=127.0.0.1
PORT=3000
DATABASE_URL=postgresql://letterpad:CHANGE_ME_PG@127.0.0.1:5432/letterpad
REDIS_URL=redis://:CHANGE_ME_REDIS@127.0.0.1:6379/0
RABBITMQ_URL=amqp://letterpad:CHANGE_ME_MQ@127.0.0.1:5672/letterpad
env.shBASH
sudo install -d -m 750 -o root -g letterpad /etc/letterpad
sudo nano /etc/letterpad/letterpad.env
sudo chown root:letterpad /etc/letterpad/letterpad.env
sudo chmod 640 /etc/letterpad/letterpad.env
TerminalOutput
$ ls -l /etc/letterpad/
-rw-r----- 1 root letterpad 312 Oct  9 10:42 letterpad.env

Run the migrations once by hand now, with the env file loaded into this shell only: set -a; source /etc/letterpad/letterpad.env; set +a; npm run migration:run. Use whatever migration script your ORM gives you.

05

Write the systemd unit

One file tells Linux how to start letterpad.io, what it waits for, who it runs as, and to restart it whenever it dies, including after a reboot.

Auto restartStart on bootHardening
What Restart=always does

Swipe sideways to see the whole diagram

systemdRestart=always, RestartSec=5runningcrash5 sstart it againrunning againtimeMore than five starts in 300 s and systemd gives up.So a broken build does not restart forever.
  1. 1Restart=always brings the app back after a crash, a kill signal, or even a clean exit.
  2. 2RestartSec=5 waits five seconds between tries, so a crash loop does not spin the CPU.
  3. 3StartLimitIntervalSec=300 with StartLimitBurst=5: more than five starts in five minutes and the unit is marked failed.

Create the file with sudo nano /etc/systemd/system/letterpad.service. The name before .service becomes the name you use with every systemctl command.

/etc/systemd/system/letterpad.serviceUNIT
[Unit]
Description=letterpad.io NestJS API
After=network-online.target postgresql@16-main.service redis-server.service rabbitmq-server.service
Wants=network-online.target postgresql@16-main.service redis-server.service rabbitmq-server.service
StartLimitIntervalSec=300
StartLimitBurst=5

[Service]
Type=simple
User=letterpad
Group=letterpad
WorkingDirectory=/srv/letterpad
EnvironmentFile=/etc/letterpad/letterpad.env
ExecStart=/usr/bin/node dist/main.js
Restart=always
RestartSec=5
KillSignal=SIGTERM
TimeoutStopSec=30
SyslogIdentifier=letterpad
LimitNOFILE=65535
StateDirectory=letterpad
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true

[Install]
WantedBy=multi-user.target

Line by line: [Unit]

DirectiveWhat it does
DescriptionThe label you see in systemctl status and in the journal.
After=Ordering only: start letterpad after the network is up and after PostgreSQL, Redis and RabbitMQ have started.
Wants=Pull those units in too, so starting letterpad starts them. If one fails, letterpad still tries, and its own retry logic takes over.
StartLimitIntervalSec=300The window for counting restarts: five minutes.
StartLimitBurst=5More than five starts inside that window and systemd gives up and marks the unit failed, so a broken build does not loop forever.

Line by line: [Service]

DirectiveWhat it does
Type=simpleThe process in ExecStart is the service. It stays in the foreground, which is how node behaves.
User / GroupRun as the locked letterpad system user, never root.
WorkingDirectoryThe folder Node starts in, so relative paths like dist/main.js resolve.
EnvironmentFileLoad every KEY=value from the env file into the process.
ExecStartThe command to run, with the full path to node. No npm start: one less process between systemd and your app.
Restart=alwaysRestart after a crash, a kill signal or even a clean exit. systemctl stop is the only thing that keeps it down.
RestartSec=5Wait five seconds before each restart, which gives a database blip time to clear.
KillSignal / TimeoutStopSecOn stop, send SIGTERM, wait up to 30 seconds for Nest to close its connections, then SIGKILL.
SyslogIdentifierTag every log line as letterpad in the journal.
LimitNOFILERaise the open file limit for many sockets: HTTP clients, pool connections, AMQP channels.
StateDirectoryCreates /var/lib/letterpad owned by the app user. The one place it may write, for uploads or temp files.
NoNewPrivilegesThe process and its children can never gain privileges, even through setuid binaries.
PrivateTmpA private /tmp that no other service can see.
ProtectSystem=strictThe whole file system is read-only for the app, except StateDirectory.
ProtectHomeHides /home, /root and /run/user from the app.

Line by line: [Install]

DirectiveWhat it does
WantedBy=multi-user.targetMakes systemctl enable hook letterpad into normal boot. This one line is what gives you start on server up.

Load it, enable it, start it

enable.shBASH
sudo systemd-analyze verify /etc/systemd/system/letterpad.service
sudo systemctl daemon-reload
sudo systemctl enable --now letterpad
systemctl status letterpad
TerminalOutput
$ systemctl status letterpad
● letterpad.service - letterpad.io NestJS API
     Loaded: loaded (/etc/systemd/system/letterpad.service; enabled; preset: enabled)
     Active: active (running) since Fri 2026-10-09 10:51:07 IST; 4s ago
   Main PID: 2241 (node)
      Tasks: 11 (limit: 4558)
     Memory: 92.4M (peak: 95.1M)
     CGroup: /system.slice/letterpad.service
             └─2241 /usr/bin/node dist/main.js

Oct 09 10:51:08 web-1 letterpad[2241]: [Nest] LOG [NestApplication] Nest application successfully started

verify prints nothing when the file is clean. daemon-reload makes systemd read the new file, and you run it again after every edit. enable --now both starts the service and turns on start at boot.

Prove the auto restart works

Kill the process the hard way, the way an out of memory kill would. Within five seconds systemd brings it back with a new PID.

crash-test.shBASH
sudo systemctl kill --signal=SIGKILL letterpad
sleep 6
systemctl show letterpad -p MainPID -p NRestarts -p ActiveState
TerminalOutput
$ systemctl show letterpad -p MainPID -p NRestarts -p ActiveState
MainPID=2318
NRestarts=1
ActiveState=active

Now the reboot test: sudo reboot, SSH back in, and run systemctl is-active letterpad. It should print active without you touching anything.

security.shBASH
systemd-analyze security letterpad

Why it matters: this scores the unit's sandboxing from 0 (locked down) to 10 (exposed). The hardening lines above take a default unit from about 9.6 to the 5s. Each extra directive it suggests is optional; add one, restart, and check the app still works.

06

Drive it with systemctl

The commands you will type every week, and how to change a setting without editing the unit file.

LifecycleInspectOverrides
CommandWhat it does
sudo systemctl start letterpadStart it now. Does not change boot behaviour.
sudo systemctl stop letterpadStop it now. Restart=always will not bring it back.
sudo systemctl restart letterpadStop then start. Use after a deploy or an env change.
sudo systemctl enable letterpadStart at boot. Add --now to also start it immediately.
sudo systemctl disable letterpadDo not start at boot. Add --now to also stop it.
systemctl status letterpadState, PID, memory and the last ten log lines.
systemctl is-active letterpadPrints one word, active or failed. Good for scripts.
systemctl is-enabled letterpadPrints enabled if it starts at boot.
systemctl cat letterpadShows the unit file plus every override, as systemd sees them.
systemctl list-units --failedEverything on the box that is currently broken.
sudo systemctl reset-failed letterpadClears the start limit counter after a crash loop, so you can start it again.

Change a setting with a drop-in

Never edit a unit that a package installed, and prefer drop-ins for your own too. systemctl edit opens an empty override file, saves it as /etc/systemd/system/letterpad.service.d/override.conf, and reloads systemd for you.

override.shBASH
sudo systemctl edit letterpad
letterpad.service.d/override.confUNIT
[Service]
# Give a slow shutdown more time and cap memory
TimeoutStopSec=60
MemoryMax=768M

Why it matters: `MemoryMax` makes the kernel kill only this service when it leaks, and `Restart=always` brings it straight back. The rest of the server never feels it.

Then sudo systemctl restart letterpad and check systemctl cat letterpad shows both files.

07

Logs and retention with journald

Everything letterpad writes to stdout lands in the journal. Make it survive reboots and cap how much disk it can use.

journalctlRetentionDisk caps
Where the logs go, and when they leave

Swipe sideways to see the whole diagram

letterpadstdout, stderrjournal: SystemMaxUse=2Gnewest, 128M per fileoldest goes firstMaxRetentionSec=30day: nothing older than 30 days, even under 2G.SystemKeepFree=4G: always leave 4G for PostgreSQL and RabbitMQ.
  1. 1Everything the unit prints lands in the journal. Read it live with journalctl -u letterpad -f.
  2. 2Files roll over at 128M, the total never passes 2G, and the oldest file goes first.
  3. 3Whichever limit hits first wins: 2G in total, 4G kept free, or 30 days of age.

Nest's logger, console.log and any crash stack trace go to stdout or stderr. systemd captures both, tags them letterpad, and stores them in the journal. No log files, no rotation scripts for the app. For production, log JSON lines with a logger such as pino so you can filter by field later.

Read the logs

CommandShows you
journalctl -u letterpad -fLive tail, like tail -f.
journalctl -u letterpad -n 200 --no-pagerThe last 200 lines, printed and done.
journalctl -u letterpad --since "1 hour ago"A time window. Also takes today, yesterday or "2026-10-09 10:00".
journalctl -u letterpad -p warningWarnings and worse only (stderr lines are logged at error level).
journalctl -u letterpad -b -1Logs from the previous boot, handy after a crash and reboot.
journalctl -u letterpad --grep "ECONNREFUSED"Search the message text.
journalctl -u letterpad -o json-pretty -n 1One entry with all its fields, including PID and timestamps.
journalctl -u letterpad -u nginx --since todayTwo services interleaved on one timeline.

Set retention once

Ubuntu keeps the journal on disk already, but its limits are percentages of the disk. Set explicit ones in a drop-in rather than editing the main journald.conf.

/etc/systemd/journald.conf.d/retention.confCONF
[Journal]
Storage=persistent
Compress=yes
SystemMaxUse=2G
SystemKeepFree=4G
SystemMaxFileSize=128M
MaxRetentionSec=30day
MaxFileSec=1week
RateLimitIntervalSec=30s
RateLimitBurst=20000
DirectiveWhat it does
Storage=persistentKeep logs in /var/log/journal so they survive reboots.
Compress=yesCompress larger entries on disk.
SystemMaxUse=2GThe journal never grows past 2 GB in total. Oldest files go first.
SystemKeepFree=4GAlways leave 4 GB free for PostgreSQL and RabbitMQ, whichever limit hits first wins.
SystemMaxFileSize=128MRotate a journal file once it reaches this size.
MaxRetentionSec=30dayDelete entries older than 30 days even if there is space.
MaxFileSec=1weekStart a new file at least weekly, so old entries can be removed in whole files.
RateLimit*Drop messages from any one service beyond 20,000 per 30 seconds, so a log storm cannot fill the disk.
journald.shBASH
sudo mkdir -p /etc/systemd/journald.conf.d
sudo nano /etc/systemd/journald.conf.d/retention.conf
sudo systemctl restart systemd-journald
journalctl --disk-usage
TerminalOutput
$ journalctl --disk-usage
Archived and active journals take up 184.0M in the file system.

Clean up by hand when you need to

vacuum.shBASH
sudo journalctl --vacuum-time=14d    # drop everything older than 14 days
sudo journalctl --vacuum-size=500M   # shrink archived files to 500 MB
sudo journalctl --verify             # check the files are not corrupted

nginx is the exception: it writes its own files under /var/log/nginx/, and Ubuntu's /etc/logrotate.d/nginx already rotates them daily and keeps 14. Change rotate 14 there if you need longer.

08

Put nginx in front

nginx takes HTTPS on 443, redirects plain HTTP, and hands every request to Nest on 127.0.0.1:3000.

Reverse proxyTLSWebSockets

Point the DNS A records for letterpad.io and www.letterpad.io at the server first. Certbot needs them to prove you own the domain.

Install nginx and get a certificate

certonly fetches the certificate without touching your config, and the deploy hook reloads nginx after every renewal so the new certificate is picked up.

nginx-install.shBASH
sudo apt install -y nginx certbot python3-certbot-nginx
sudo ufw allow 'Nginx Full'          # module 09 explains this
sudo certbot certonly --nginx -d letterpad.io -d www.letterpad.io \
  --deploy-hook "systemctl reload nginx"
TerminalOutput
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/letterpad.io/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/letterpad.io/privkey.pem
Certbot has set up a scheduled task to automatically renew this certificate in the background.

The site config

Create /etc/nginx/sites-available/letterpad.io with this content.

/etc/nginx/sites-available/letterpad.ioNGINX
upstream letterpad_api {
    server 127.0.0.1:3000;
    keepalive 32;
}

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      '';
}

server {
    listen 80;
    listen [::]:80;
    server_name letterpad.io www.letterpad.io;
    return 301 https://letterpad.io$request_uri;
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name letterpad.io www.letterpad.io;

    ssl_certificate     /etc/letsencrypt/live/letterpad.io/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/letterpad.io/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 1d;

    add_header Strict-Transport-Security "max-age=31536000" always;
    add_header X-Content-Type-Options "nosniff" always;
    client_max_body_size 10m;

    access_log /var/log/nginx/letterpad.access.log;
    error_log  /var/log/nginx/letterpad.error.log warn;

    location / {
        proxy_pass http://letterpad_api;
        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;
        proxy_set_header Upgrade           $http_upgrade;
        proxy_set_header Connection        $connection_upgrade;
        proxy_connect_timeout 5s;
        proxy_read_timeout    60s;
        proxy_send_timeout    60s;
    }

    location = /health {
        proxy_pass http://letterpad_api;
        access_log off;
    }
}

Line by line

DirectiveWhat it does
upstream letterpad_apiNames the backend once, so every proxy_pass points at the same place.
server 127.0.0.1:3000Where Nest listens. Matches HOST and PORT in the env file.
keepalive 32Keeps up to 32 idle connections open to Nest, saving a TCP handshake per request.
map $http_upgradeSends Connection: upgrade only when the client asks for a WebSocket, and an empty header otherwise, which keeps upstream keepalive working.
listen 80 + return 301Every plain HTTP request is redirected to https://letterpad.io, which also folds www into the bare domain.
listen 443 ssl http2HTTPS with HTTP/2, on IPv4 and IPv6. On nginx 1.25.1 or newer, write listen 443 ssl; plus http2 on; instead.
ssl_certificate*The files Certbot wrote. Renewals replace them in place.
ssl_protocolsOnly TLS 1.2 and 1.3.
ssl_session_cacheReuses TLS sessions across workers, so returning clients skip the full handshake.
Strict-Transport-SecurityTells browsers to use HTTPS for this domain for a year.
client_max_body_size 10mThe largest request body accepted. nginx rejects bigger uploads with 413 before Nest sees them.
access_log / error_logA separate pair of log files for this site.
proxy_http_version 1.1Required for keepalive and WebSockets to the upstream.
Host, X-Real-IP, X-Forwarded-*Pass the original host, client IP and scheme to Nest. With trust proxy set in main.ts, req.ip and req.protocol are correct.
Upgrade / ConnectionLets Socket.IO and other WebSocket traffic through.
proxy_*_timeoutFail fast if Nest is down (5s to connect), allow 60s for a slow response.
location = /healthExact match for the health route, with access logging off so monitors do not flood the log.

Enable the site and reload

nginx-enable.shBASH
sudo ln -s /etc/nginx/sites-available/letterpad.io /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
curl -I https://letterpad.io/health
TerminalOutput
$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
$ curl -I https://letterpad.io/health
HTTP/2 200
content-type: application/json; charset=utf-8
strict-transport-security: max-age=31536000

Always run nginx -t before reload. A reload with a broken config is refused and the old config keeps serving, but a restart with one takes the site down. Check renewal works with sudo certbot renew --dry-run.

09

Close everything else with ufw

Deny all incoming traffic, then open exactly three doors: SSH, HTTP and HTTPS.

Default denySSH firstThree ports

ufw is a friendly front end to the kernel firewall. The order below matters: allow SSH before you enable the firewall, or you lock yourself out of the server.

ufw.shBASH
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw limit OpenSSH              # allow SSH, but slow down brute force
sudo ufw allow 'Nginx Full'         # 80 and 443
sudo ufw logging low
sudo ufw enable
sudo ufw status verbose
TerminalOutput
$ sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), deny (routed)

To                         Action      From
--                         ------      ----
22/tcp (OpenSSH)           LIMIT IN    Anywhere
80,443/tcp (Nginx Full)    ALLOW IN    Anywhere
22/tcp (OpenSSH (v6))      LIMIT IN    Anywhere (v6)
80,443/tcp (Nginx Full (v6)) ALLOW IN    Anywhere (v6)
RuleWhat it does
default deny incomingAnything not explicitly allowed is dropped, including 5432, 6379, 5672, 4369 and 25672.
default allow outgoingThe server can still reach apt, npm, GitHub and Let's Encrypt.
limit OpenSSHAllows port 22 but blocks an IP that opens 6 connections within 30 seconds.
allow 'Nginx Full'An app profile shipped with nginx that opens 80 and 443.
logging lowLogs blocked packets to the kernel log, readable with journalctl -k --grep UFW.

Check what is actually listening

The firewall is the second wall. The first is that the services only listen on localhost. ss shows both at once.

ports.shBASH
sudo ss -tlnp
TerminalOutput
$ sudo ss -tlnp
State   Local Address:Port   Process
LISTEN  0.0.0.0:22           sshd
LISTEN  0.0.0.0:80           nginx
LISTEN  0.0.0.0:443          nginx
LISTEN  127.0.0.1:3000       node
LISTEN  127.0.0.1:5432       postgres
LISTEN  127.0.0.1:6379       redis-server
LISTEN  127.0.0.1:5672       beam.smp
LISTEN  0.0.0.0:25672        beam.smp
LISTEN  0.0.0.0:4369         epmd
# 25672 and 4369 are public on the socket but blocked by ufw

Rules you will need later

NeedCommand
SSH on a custom portsudo ufw limit 2222/tcp then sudo ufw delete limit OpenSSH
A second app server needs PostgreSQLsudo ufw allow from 10.0.0.12 to any port 5432 proto tcp and widen listen_addresses
See rules with numberssudo ufw status numbered
Delete rule 3sudo ufw delete 3
RabbitMQ admin UIDo not open 15672. Tunnel it: ssh -L 15672:127.0.0.1:15672 deploy@server

If PostgreSQL, Redis or RabbitMQ are managed services instead, nothing changes here: outgoing traffic is already allowed. Remove their units from After= and Wants= in module 05.

10

Ship updates

One script pulls, builds, migrates, restarts and checks health. If health fails, it prints the last log lines.

Deploy scriptsudoersHealth gate
One deploy, step by step

Swipe sideways to see the whole diagram

git resetorigin/mainnpm cifrom the lockfilenpm run buildcompilemigratemigration:runnpm prune--omit=devrestartSIGTERM, drain/healthup to 20 triesdoneexit 0, or logsset -euo pipefail: the first failing step stops the script.So a broken build never reaches the restart.
  1. 1git reset --hard origin/main and npm ci give the server the same tree every time.
  2. 2set -euo pipefail stops at the first failure, so a broken build is never restarted into production.
  3. 3After the restart the script polls /health for up to 20 seconds, and prints the last 50 journal lines if it never answers.
/srv/letterpad/scripts/deploy.shBASH
#!/usr/bin/env bash
set -euo pipefail
cd /srv/letterpad

git fetch --prune origin
git reset --hard origin/main
npm ci
npm run build

set -a; source /etc/letterpad/letterpad.env; set +a
npm run migration:run
npm prune --omit=dev

sudo systemctl restart letterpad

for i in $(seq 1 20); do
  if curl -fsS http://127.0.0.1:3000/health > /dev/null; then
    echo "letterpad is healthy"; exit 0
  fi
  sleep 1
done

echo "health check failed"
journalctl -u letterpad -n 50 --no-pager
exit 1
StepWhy
set -euo pipefailStop at the first failing command, so a broken build never gets restarted into production.
git reset --hard origin/mainThe server always matches the branch exactly, with no stray local edits.
npm ciA clean install from the lockfile, the same tree every time.
source the env fileMigrations need DATABASE_URL. set -a exports every line for this script only.
systemctl restartNest gets SIGTERM, drains, exits, and the new build starts.
Health loopWaits up to 20 seconds for /health. On failure it shows you why, from the journal.

Let deploy restart only this service

A sudoers rule lets the script restart letterpad without a password, and nothing else. Always edit sudoers with visudo: it refuses to save a file with a syntax error.

sudoers.shBASH
sudo visudo -f /etc/sudoers.d/letterpad-deploy
/etc/sudoers.d/letterpad-deploySUDOERS
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart letterpad, /usr/bin/systemctl status letterpad
TerminalOutput
$ chmod +x scripts/deploy.sh && ./scripts/deploy.sh
added 612 packages in 14s
> letterpad@1.4.0 build
> nest build
No migrations are pending
letterpad is healthy

Which one do I need?

The whole guide as one table. Find the job, run the command.

Cheat sheet
JobWhereCommand
App is downsystemdsystemctl status letterpad
Why did it crash?journaldjournalctl -u letterpad -b -n 200
Watch logs livejournaldjournalctl -u letterpad -f
Restart after an env changesystemdsudo systemctl restart letterpad
Edited the unit filesystemdsudo systemctl daemon-reload
Stuck in a crash loopsystemdsudo systemctl reset-failed letterpad
Change a limit safelysystemdsudo systemctl edit letterpad
Starts on boot?systemdsystemctl is-enabled letterpad
Logs using too much diskjournaldsudo journalctl --vacuum-size=500M
Changed nginx confignginxsudo nginx -t && sudo systemctl reload nginx
502 Bad Gatewaynginxtail -n 50 /var/log/nginx/letterpad.error.log
Certificate renewalCertbotsudo certbot renew --dry-run
What is open?ufwsudo ufw status verbose
What is listening?Linuxsudo ss -tlnp
Ship a new versionScript./scripts/deploy.sh

A 502 from nginx almost always means Nest is not listening: check systemctl status letterpad first, then the journal. A crash loop that stops after five tries means the start limit fired, so fix the cause, run reset-failed, and start it again.