How to Build and Host a REST API on a VPS: Node.js, FastAPI, or Laravel with SSL and Rate Limiting
A VPS is an excellent platform for hosting REST APIs — you control the runtime environment, can tune performance for your specific workload, and pay a fixed monthly cost regardless of request volume (unlike serverless functions that bill per invocation). This guide covers hosting a production REST API on a VPS across three popular stacks: Node.js with Express or Fastify, Python with FastAPI, and PHP with Laravel API. All three share a common deployment pattern: application server behind an Nginx reverse proxy with SSL and rate limiting.
Common Architecture for All Stacks
Regardless of which language and framework you choose, a production VPS API deployment follows the same pattern:
Client → Cloudflare (optional DDoS/CDN) → Nginx (SSL, rate limiting, routing)
→ Application Server (Node.js/Gunicorn/PHP-FPM)
→ Database (PostgreSQL/MariaDB) + Cache (Redis)
Nginx handles SSL termination and rate limiting for all frameworks — this means the rate limiting configuration below applies regardless of your language choice.
Nginx Base Configuration for APIs
sudo nano /etc/nginx/sites-available/api.yourdomain.com
# Rate limiting zones
limit_req_zone $binary_remote_addr zone=api_general:10m rate=60r/m;
limit_req_zone $http_x_api_key zone=api_by_key:10m rate=1000r/m;
# Connection limiting
limit_conn_zone $binary_remote_addr zone=api_conn:10m;
upstream api_backend {
server 127.0.0.1:8000; # Change port to match your framework
keepalive 32;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name api.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/api.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.yourdomain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# API-specific headers
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Content-Security-Policy "default-src 'none'" always;
# Apply rate limiting
limit_conn api_conn 20;
limit_req zone=api_general burst=20 nodelay;
limit_req_status 429;
location / {
proxy_pass http://api_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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_cache_bypass $http_upgrade;
proxy_read_timeout 30s;
proxy_connect_timeout 10s;
# Return proper 429 JSON for rate limit responses
error_page 429 = @rate_limited;
}
location @rate_limited {
default_type application/json;
add_header Retry-After 60 always;
return 429 '{"error":"Rate limit exceeded","retry_after":60}';
}
}
sudo ln -s /etc/nginx/sites-available/api.yourdomain.com /etc/nginx/sites-enabled/
sudo certbot --nginx -d api.yourdomain.com
sudo nginx -t && sudo systemctl reload nginx
Option A: Node.js API with Express/Fastify
Deploy a Fastify API (Recommended for Performance)
sudo apt install nodejs npm -y
# Or install via NodeSource for latest LTS:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash -
sudo apt install nodejs -y
sudo npm install -g pm2
A production-ready Fastify API with authentication and rate limiting:
// server.js
const fastify = require('fastify')({ logger: true })
const rateLimit = require('@fastify/rate-limit')
const jwt = require('@fastify/jwt')
fastify.register(jwt, { secret: process.env.JWT_SECRET })
fastify.register(rateLimit, {
max: 100,
timeWindow: '1 minute',
keyGenerator: (request) =>
request.headers['x-api-key'] || request.ip
})
// Health endpoint (no auth)
fastify.get('/health', async () => ({ status: 'ok', ts: Date.now() }))
// Authenticated route
fastify.get('/api/v1/data',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
return { data: [], user: request.user }
}
)
fastify.decorate('authenticate', async (request, reply) => {
try {
await request.jwtVerify()
} catch (err) {
reply.send(err)
}
})
const start = async () => {
try {
await fastify.listen({ port: 8000, host: '127.0.0.1' })
} catch (err) {
fastify.log.error(err)
process.exit(1)
}
}
start()
pm2 start server.js --name api --instances max
pm2 startup && pm2 save
Option B: Python FastAPI
FastAPI is the fastest-growing Python API framework, combining async performance, automatic OpenAPI documentation, and type-safe request/response validation:
sudo apt install python3-pip python3-venv -y
mkdir -p /var/www/api && cd /var/www/api
python3 -m venv venv
source venv/bin/activate
pip install fastapi uvicorn[standard] gunicorn python-jose[cryptography] \
passlib[bcrypt] sqlalchemy psycopg2-binary python-dotenv
# main.py
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from typing import Optional
import os
app = FastAPI(title="My API", version="1.0.0", docs_url="/docs")
SECRET_KEY = os.getenv("SECRET_KEY")
ALGORITHM = "HS256"
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
async def get_current_user(token: str = Depends(oauth2_scheme)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id: Optional[str] = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
return {"user_id": user_id}
@app.get("/health")
async def health():
return {"status": "ok"}
@app.get("/api/v1/items", dependencies=[Depends(get_current_user)])
async def get_items(current_user: dict = Depends(get_current_user)):
return {"items": [], "user_id": current_user["user_id"]}
Run with Gunicorn for production:
sudo nano /etc/systemd/system/fastapi.service
[Unit]
Description=FastAPI application
After=network.target
[Service]
User=www-data
WorkingDirectory=/var/www/api
EnvironmentFile=/var/www/api/.env
ExecStart=/var/www/api/venv/bin/gunicorn main:app \
-w 4 \
-k uvicorn.workers.UvicornWorker \
--bind 127.0.0.1:8000 \
--access-logfile /var/log/fastapi/access.log \
--error-logfile /var/log/fastapi/error.log
Restart=on-failure
[Install]
WantedBy=multi-user.target
sudo mkdir -p /var/log/fastapi && sudo chown www-data /var/log/fastapi
sudo systemctl daemon-reload
sudo systemctl enable fastapi && sudo systemctl start fastapi
FastAPI automatically generates interactive API documentation at /docs (Swagger UI) and /redoc. Disable these in production or protect them with authentication.
Option C: Laravel API
Laravel’s API capabilities — Sanctum for token authentication, API Resources for response transformation, and built-in rate limiting — make it a strong choice for PHP teams:
composer create-project laravel/laravel /var/www/api
cd /var/www/api
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
Configure Nginx to use PHP-FPM on port 8000 (or use the standard PHP-FPM socket approach from the Laravel deployment guide):
// routes/api.php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', function (Request $request) {
return $request->user();
});
Route::apiResource('items', ItemController::class);
});
// Rate limiting (in RouteServiceProvider)
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
API Security Best Practices for All Stacks
API Key Management
# Generate a secure API key
python3 -c "import secrets; print(secrets.token_urlsafe(32))"
# Or
openssl rand -base64 32
Store API keys hashed (not in plain text) and validate by hashing the incoming key and comparing:
import hashlib
import secrets
def hash_api_key(key: str) -> str:
return hashlib.sha256(key.encode()).hexdigest()
def verify_api_key(plain_key: str, hashed_key: str) -> bool:
return secrets.compare_digest(hash_api_key(plain_key), hashed_key)
Response Headers for APIs
# Add to Nginx location block
add_header X-RateLimit-Limit 60 always;
add_header X-RateLimit-Remaining $limit_req_status always;
add_header Access-Control-Allow-Origin "https://yourfrontend.com" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-API-Key" always;
Input Validation
Never trust client input. For all three stacks:
- FastAPI: Pydantic models provide automatic type validation and sanitization
- Laravel: Form Request classes with rules() method provide declarative validation
- Node.js/Fastify: JSON Schema validation is built into Fastify’s route schema
Logging API Requests
# Nginx API access log with timing and upstream info
log_format api_log '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'rt=$request_time uct=$upstream_connect_time '
'uht=$upstream_header_time urt=$upstream_response_time';
access_log /var/log/nginx/api.access.log api_log;
Getting Started
A REST API VPS deployment starts comfortably at 2 vCPU / 2 GB RAM for most workloads, scaling to 4 vCPU / 4 GB RAM for higher-throughput applications. Ubuntu VPS plans at VPS.DO provide the environment for Node.js, Python, and PHP API deployments, with NVMe storage for fast database queries and KVM virtualization for full compatibility with all language runtimes.
Conclusion
Hosting a REST API on a VPS requires four components: an application server running your framework (PM2/Gunicorn/PHP-FPM), an Nginx reverse proxy for SSL and rate limiting, a database for persistence, and a Redis cache for performance. The specific framework choice — Node.js/Fastify, Python/FastAPI, or Laravel — affects the application server configuration but not the Nginx or infrastructure layer. All three deliver production-grade API performance on modest VPS hardware when properly configured.