People often search for “Fastify vs Nginx” as if you have to pick one, but they solve different problems. Fastify is a Node.js framework: it runs your application code. Nginx is a web server and reverse proxy: it sits in front of applications and handles the messy parts of facing the internet, TLS termination, compression, static files, rate limiting, and buffering slow clients. In production, the standard architecture is both: Nginx in front, Fastify behind it.
This tutorial builds that architecture on Ubuntu, end to end. You will run a Fastify app as a proper systemd service (so it survives crashes and reboots), put Nginx in front of it as a reverse proxy, forward the real client IP correctly, enable connection keepalive between the two, and terminate SSL at Nginx.
This guide is for developers who have a working Fastify (or any Node.js) app and want to deploy it properly on a Linux server. If you do not have an app yet, build one first with Build a REST API with Fastify and MySQL on Ubuntu.
Why Put Nginx in Front of Fastify at All?
Fastify is genuinely fast (that is the whole premise of Comparing Fastify vs Express), and Node.js can absolutely listen on port 443 directly. So why add a hop?
- Privileged ports and TLS. Ports 80/443 require root or special capabilities. Nginx handles them as a hardened system service, while your Node process runs as an unprivileged user on port 3000.
- TLS termination. Certificates, renewals, protocol tuning, and cipher configuration live in one well-understood place instead of inside your app.
- Slow client buffering. Nginx absorbs slow mobile connections and hands your app complete requests, so a slow reader never occupies your Node.js event loop resources for long.
- One entry point for many apps. The day you deploy a second service, it is one more
locationorserverblock, not another port exposed to the world. - Cheap extras. Compression, static file serving, and rate limiting are single directives in Nginx.
The request flow we are building:
client --HTTPS--> nginx (:443) --HTTP--> fastify (127.0.0.1:3000)
Prerequisites
- Ubuntu 22.04 or 24.04 with a user that has
sudoprivileges - Node.js 20 or 22 installed
- Nginx installed (
sudo apt install nginx) - A domain name pointing at the server if you want real SSL (we use
api.example.com)
Step 1: A Minimal Fastify App with trustProxy
If you already have an app, only one change matters here: the trustProxy option. Create or adjust the entry point:
mkdir -p ~/fastify-app && cd ~/fastify-app
npm init -y && npm install fastify
npm pkg set type=module
nano server.js
import Fastify from 'fastify'
const fastify = Fastify({
logger: true,
trustProxy: '127.0.0.1'
})
fastify.get('/', async (request) => {
return {
message: 'hello from fastify behind nginx',
clientIp: request.ip,
protocol: request.protocol
}
})
fastify.get('/health', async () => ({ status: 'ok' }))
await fastify.listen({ port: 3000, host: '127.0.0.1' })
Two deliberate choices:
trustProxy: '127.0.0.1'tells Fastify to trust theX-Forwarded-*headers, but only when the request comes from localhost, which is where Nginx lives. With this set,request.ipreturns the real visitor address andrequest.protocolreturnshttpswhen the original request was encrypted.host: '127.0.0.1'binds the app to loopback only. Nobody can bypass Nginx and hit port 3000 from outside, even if the firewall misses it.
Quick test:
node server.js &
curl -s http://127.0.0.1:3000/health
{"status":"ok"}
Stop the test process (kill %1) before continuing.
Step 2: Run the App as a systemd Service
node server.js in a terminal dies with your SSH session. A systemd unit gives you auto-start at boot, auto-restart on crash, and centralized logs.
Create a system user to run the app (running services as your login user or root is a habit worth breaking):
sudo useradd --system --home /opt/fastify-app --shell /usr/sbin/nologin fastify
sudo mkdir -p /opt/fastify-app
sudo cp -r ~/fastify-app/* /opt/fastify-app/
sudo chown -R fastify:fastify /opt/fastify-app
Create the unit file:
sudo nano /etc/systemd/system/fastify-app.service
[Unit]
Description=Fastify application
After=network.target
[Service]
Type=simple
User=fastify
WorkingDirectory=/opt/fastify-app
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=2
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.target
Start and enable it:
sudo systemctl daemon-reload
sudo systemctl enable --now fastify-app
sudo systemctl status fastify-app
● fastify-app.service - Fastify application
Active: active (running)
Logs now flow to the journal:
sudo journalctl -u fastify-app -f
If systemd units are new territory, I wrote a dedicated guide: Manage Background Services and Timers with systemd on Ubuntu.
Step 3: Configure Nginx as the Reverse Proxy
Create the site configuration:
sudo nano /etc/nginx/sites-available/api.example.com
upstream fastify_backend {
server 127.0.0.1:3000;
keepalive 16;
}
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://fastify_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
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;
}
location /health {
proxy_pass http://fastify_backend;
access_log off;
}
}
Every line earns its place:
- The
upstreamblock withkeepalive 16keeps a small set of idle connections open from Nginx to Fastify. Without it, every single request pays a new local TCP handshake.proxy_http_version 1.1and the emptyConnectionheader are required for keepalive to actually work. Host $hostpreserves the domain the client asked for, so Fastify seesapi.example.cominstead of the upstream name.X-Real-IPandX-Forwarded-Forcarry the visitor’s address; this is whattrustProxyreads on the Fastify side.X-Forwarded-Prototells the app whether the original request was HTTP or HTTPS, which matters for redirects and secure cookies.
Enable the site and reload:
sudo ln -s /etc/nginx/sites-available/api.example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Test through the proxy from another machine (or with a Host header):
curl -s http://api.example.com/ | jq
{
"message": "hello from fastify behind nginx",
"clientIp": "203.0.113.7",
"protocol": "http"
}
The clientIp shows the real visitor address, not 127.0.0.1. The header forwarding works.
Step 4: Add SSL Termination
Terminate TLS at Nginx with a free Let’s Encrypt certificate:
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d api.example.com
Certbot edits your server block to listen on 443 with the certificate and adds the HTTP to HTTPS redirect. I covered the details, renewal included, in Secure Nginx with Let’s Encrypt SSL Using Certbot on Ubuntu.
Verify the full chain:
curl -s https://api.example.com/ | jq .protocol
"https"
Fastify reports https even though the app itself only speaks plain HTTP on loopback. That is X-Forwarded-Proto plus trustProxy doing their job, and it is exactly how the app should learn about encryption: from headers set by a proxy it trusts, not by handling certificates itself.
If your app uses websockets, add these two lines to the location block so upgrade requests pass through:
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
Step 5: Lock Down the Firewall
The final shape: only Nginx faces the network.
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw status
To Action From
-- ------ ----
OpenSSH ALLOW Anywhere
Nginx Full ALLOW Anywhere
Port 3000 is not in the list, and since Fastify binds to 127.0.0.1 anyway, the app has two independent layers keeping direct traffic out.
Common Mistakes and Troubleshooting
502 Bad Gateway. Nginx cannot reach the app. Check the service (sudo systemctl status fastify-app), then confirm something listens on 3000: sudo ss -tlnp | grep 3000. The Nginx error log (/var/log/nginx/error.log) states the exact connect error.
request.ip is always 127.0.0.1. Either trustProxy is missing in the Fastify options, or the X-Forwarded-For header is not being set in the Nginx config. Both sides must cooperate.
Redirect loops after enabling SSL. Your app redirects HTTP to HTTPS itself but sees only plain HTTP from Nginx. Let Nginx own that redirect, or make the app check X-Forwarded-Proto (which request.protocol already does when trustProxy is set).
Websockets disconnect immediately. The Upgrade and Connection "upgrade" headers from Step 4 are missing on the websocket path.
Slow responses under load that the app alone does not show. Check that upstream keepalive is really active (proxy_http_version 1.1 plus empty Connection header). Without it, high request rates burn ports on handshakes and can even exhaust ephemeral ports.
404 for every path except /. You wrote proxy_pass http://fastify_backend/; with a trailing slash inside a non-root location, which rewrites paths. For a simple full-site proxy, use the exact form shown in Step 3, no trailing slash.
Best Practices
- Bind Node.js apps to loopback and let the reverse proxy be the only public listener. It is one line and eliminates a whole class of misconfigurations.
- Scope
trustProxyto the proxy’s address, nottrue. Trusting arbitraryX-Forwarded-Forheaders from anywhere lets clients spoof their IP to your app. - Run one systemd service per app with
Restart=on-failure. For multi-core machines, run one instance per core on ports 3000-3003 and list them all in theupstreamblock; Nginx load balances them automatically, the same mechanism I describe in Configure Nginx as Layer 7 Load Balancer. - Rate limit at the proxy, before requests spend your application’s CPU: Configure Nginx Rate Limiting on Ubuntu.
- Keep compression at Nginx too (gzip for JSON responses is nearly free), so the Node process spends its time on application work.
Conclusion
You deployed the standard production architecture for Node.js applications: Fastify running as a hardened systemd service on loopback, and Nginx in front handling TLS, real IP forwarding, keepalive connections, and the public ports. The “Fastify vs Nginx” question answers itself once you see the roles: Fastify is your application, Nginx is your front door.
From here, add rate limiting to protect the API, and load test the whole stack before launch day with Load Testing with k6 on Ubuntu so you know your numbers instead of guessing them.