Your framework handles routes and business logic; the server around it decides whether the API is safe to put on the internet. This guide covers the infrastructure side of running a REST API on your own VPS — process management, the reverse proxy, TLS, authentication, input handling, rate limiting and logging — with Node.js examples that translate directly to Python, PHP or Go.
The Shape of a Production API
Client → Firewall → Nginx (TLS, rate limits, headers) → App on 127.0.0.1 → Database on 127.0.0.1
Only Nginx faces the internet. The application and the database listen on the loopback address, so nothing can reach them except through the proxy.
Step 1 — Lock Down the Server First
sudo ufw default deny incoming
sudo ufw allow OpenSSH
sudo ufw allow 80,443/tcp
sudo ufw enable
Use SSH keys and turn off password login before the API goes live — see How to Harden a Fresh Linux VPS.
Step 2 — Bind the App to Localhost
app.listen(3000, '127.0.0.1');
Never expose the application port directly. If ss -tulpn shows it on 0.0.0.0, it is reachable from anywhere the firewall allows.
Step 3 — Keep It Running with a Process Manager
Run the app under systemd (or PM2) so it restarts on crash and on reboot, and runs as an unprivileged user rather than root:
[Unit]
Description=My API
After=network.target
[Service]
User=api
WorkingDirectory=/srv/api
EnvironmentFile=/srv/api/.env
ExecStart=/usr/bin/node server.js
Restart=on-failure
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
sudo systemctl enable --now api
Which to choose is covered in PM2 vs systemd vs Supervisor.
Step 4 — Keep Secrets Out of the Code
Database passwords, JWT signing keys and API tokens belong in an environment file readable only by the service user — never in the repository:
sudo chown api:api /srv/api/.env
sudo chmod 600 /srv/api/.env
Step 5 — Put Nginx in Front, with TLS
server {
server_name api.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
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;
client_max_body_size 1m;
}
}
sudo certbot --nginx -d api.example.com
Certbot adds HTTPS and the redirect from HTTP. Never accept credentials or tokens over plain HTTP. Full proxy setup: Nginx as a Reverse Proxy for Node.js and Docker Apps. If the API has no domain yet, see IP-only TLS options.
Behind a proxy, tell the framework to trust it so client IPs in logs and rate limits are real: in Express, app.set('trust proxy', 'loopback').
Step 6 — Authenticate and Authorise
Authentication proves who the caller is; authorisation decides what they may touch. Check both on every request, and check ownership on every object (“can this user read invoice 123?”), not just whether they are logged in. Token-based authentication is covered in How to Set Up API Authentication with JWT.
Step 7 — Validate Every Input on the Server
const { body, validationResult } = require('express-validator');
app.post('/users',
body('email').isEmail().normalizeEmail(),
body('age').isInt({ min: 0, max: 150 }),
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
// ... handle valid input
});
Client-side validation is for user experience; only server-side validation is security.
Step 8 — Use Parameterised Queries
// Vulnerable to SQL injection
db.query(`SELECT * FROM users WHERE email = '${email}'`);
// Safe
db.query('SELECT * FROM users WHERE email = ?', [email]);
Step 9 — Rate Limit
# in the http {} block
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
# in the location {} block
limit_req zone=api burst=20 nodelay;
Stricter limits belong on login and password-reset endpoints. Details in How to Rate Limit an API with Nginx.
Step 10 — Set CORS and Security Headers Deliberately
app.use(cors({ origin: 'https://app.example.com', methods: ['GET', 'POST', 'PUT', 'DELETE'] }));
app.use(helmet());
Avoid origin: '*' on any API that uses cookies or returns private data.
Step 11 — Return Errors Without Leaking Internals
app.use((err, req, res, next) => {
console.error(err); // full detail to your logs
res.status(500).json({ error: 'Internal server error' }); // nothing internal to the client
});
Step 12 — Log, Monitor, Version
- Request logs with status and latency (
morgan, or Nginx access logs), rotated so they do not fill the disk. - A
/healthendpoint for uptime checks — see How to Set Up Uptime Monitoring. - Versioned routes (
/v1/) so you can change the API without breaking clients.
Production Checklist
- Firewall on; only 22, 80 and 443 open
- App and database on 127.0.0.1, app running as a non-root user
- HTTPS everywhere, certificates renewing
- Secrets in a 600-permission env file, not in Git
- Authentication and per-object authorisation on every route
- Server-side validation and parameterised queries
- Rate limits, tighter on auth endpoints
- No stack traces in responses; logs rotated; health check monitored
Choosing a Server for It
Most APIs are database-bound long before they are CPU-bound, so fast storage matters more than core count — NVMe VPS is the usual fit. Every plan includes full root access and a dedicated IPv4; VPS for developers covers what to pick for staging versus production.
Common Problems
502 Bad Gateway — the app is not running or not on the port Nginx proxies to: systemctl status api and ss -tulpn | grep 3000. See How to Fix Common Nginx Errors.
Every request appears to come from 127.0.0.1 — the framework is not trusting the proxy headers; set trust proxy.
CORS errors in the browser — the origin must match the front end’s scheme, host and port exactly.
Continue Reading
- How to Set Up API Authentication with JWT
- How to Rate Limit an API with Nginx
- How to Conduct a Security Audit of Your VPS
Browse more articles in Object Storage, Messaging & APIs.