Hosting an API is not the same as hosting a website. Websites serve HTML to browsers. APIs serve JSON to machines — mobile apps, frontend SPAs, third-party integrations, IoT devices, and other servers. The traffic patterns are different (higher request rates, smaller payloads, stateless), the security requirements are different (API keys, JWTs, CORS), and the monitoring needs are different (p99 latency matters more than page load time). This guide covers the complete API hosting stack: deployment, reverse proxy configuration, security hardening, rate limiting, authentication, monitoring, and capacity planning.

MassiveGRID Ubuntu VPS includes: Ubuntu 24.04 LTS pre-installed · Proxmox HA cluster with automatic failover · Ceph 3x replicated NVMe storage · Independent CPU/RAM/storage scaling · 12 Tbps DDoS protection · 4 global datacenter locations · 100% uptime SLA · 24/7 human support rated 9.5/10

Deploy a self-managed VPS — from $1.99/mo
Need dedicated resources? — from $19.80/mo
Want fully managed hosting? — we handle everything

API Hosting vs Website Hosting

Before diving into configuration, understand the fundamental differences between hosting a REST API and hosting a traditional website:

Aspect Website REST API
Response format HTML, CSS, JS, images JSON (small payloads, 1-50 KB)
Client type Browsers Mobile apps, SPAs, servers, scripts
Request rate 1-5 req/sec per user 10-100+ req/sec per client
State Sessions, cookies Stateless (auth per request)
Caching Browser cache, CDN ETags, Cache-Control headers
Auth mechanism Login forms, sessions API keys, JWT, OAuth2
Cross-origin Same-origin (usually) CORS required for browser clients
Performance metric Page load time p50/p99 response latency

Prerequisites

This guide assumes you have:

Deployment Patterns Overview

Your API needs a process manager to keep it running, restart it on crash, and manage logs. The choice depends on your language:

Runtime Process Manager Guide
Node.js PM2 Deploy Node.js with PM2
Python Gunicorn + systemd Deploy Python with Gunicorn
Go systemd (binary) Covered below

For a Go API, create a systemd service directly:

# Build the Go binary
cd /home/deploy/api
go build -o api-server ./cmd/server

# Create systemd service
sudo tee /etc/systemd/system/api.service > /dev/null << 'EOF'
[Unit]
Description=REST API Server
After=network.target postgresql.service

[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/home/deploy/api
ExecStart=/home/deploy/api/api-server
Restart=always
RestartSec=5
Environment=PORT=3000
Environment=DATABASE_URL=postgres://appuser:password@localhost:5432/myapp
Environment=GIN_MODE=release
LimitNOFILE=65535

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now api

Nginx as API Reverse Proxy

API reverse proxy configuration differs from website configuration. You need optimized timeouts, proper header forwarding, and JSON-aware error handling.

# /etc/nginx/sites-available/api.conf
upstream api_backend {
    server 127.0.0.1:3000;
    keepalive 32;              # Persistent connections to backend
}

server {
    listen 80;
    server_name api.yourdomain.com;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name api.yourdomain.com;

    # SSL configuration (see Let's Encrypt guide)
    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;
    ssl_ciphers HIGH:!aNULL:!MD5;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;

    # API-specific: Disable buffering for streaming responses
    proxy_buffering off;

    # API-specific: Increase request body size for file uploads
    client_max_body_size 50M;

    # API-specific: Timeouts tuned for API workloads
    proxy_connect_timeout 10s;
    proxy_send_timeout 30s;
    proxy_read_timeout 30s;

    # Return JSON error responses instead of HTML
    error_page 502 503 504 /api_error_502.json;
    location = /api_error_502.json {
        internal;
        default_type application/json;
        return 502 '{"error": "Service temporarily unavailable", "status": 502}';
    }

    error_page 429 /api_error_429.json;
    location = /api_error_429.json {
        internal;
        default_type application/json;
        return 429 '{"error": "Rate limit exceeded", "status": 429}';
    }

    location / {
        proxy_pass http://api_backend;
        proxy_http_version 1.1;

        # Essential proxy headers
        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;

        # Request ID for tracing
        proxy_set_header X-Request-ID $request_id;

        # Keepalive to backend
        proxy_set_header Connection "";

        # Security headers
        add_header X-Content-Type-Options "nosniff" always;
        add_header X-Frame-Options "DENY" always;
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    }
}
# Enable the site and test
sudo ln -s /etc/nginx/sites-available/api.conf /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

For SSL/TLS certificate setup, follow our Let's Encrypt SSL guide.

Rate Limiting

APIs need rate limiting at both the Nginx level (to protect the server) and the application level (for per-user/per-key limits).

Nginx-Level Rate Limiting

# Add to the http block in /etc/nginx/nginx.conf (or a conf.d file)

# Define rate limit zones
limit_req_zone $binary_remote_addr zone=api_general:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=api_auth:10m rate=5r/s;
limit_req_zone $binary_remote_addr zone=api_heavy:10m rate=2r/s;
# Apply in your server block
location /api/v1/ {
    limit_req zone=api_general burst=20 nodelay;
    limit_req_status 429;
    proxy_pass http://api_backend;
    # ... other proxy settings
}

# Stricter limits on authentication endpoints
location /api/v1/auth/ {
    limit_req zone=api_auth burst=5 nodelay;
    limit_req_status 429;
    proxy_pass http://api_backend;
}

# Very strict limits on expensive operations
location /api/v1/reports/ {
    limit_req zone=api_heavy burst=3 nodelay;
    limit_req_status 429;
    proxy_pass http://api_backend;
}

The rate=30r/s means 30 requests per second per IP. The burst=20 allows short bursts above the rate before throttling. nodelay means excess requests in the burst are processed immediately rather than queued.

Application-Level Rate Limiting

For per-API-key rate limiting, implement it in your application using Redis as the counter store:

// Node.js example with express-rate-limit and Redis
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const Redis = require('ioredis');

const redis = new Redis({
  host: '127.0.0.1',
  port: 6379
});

// Rate limit by API key
const apiKeyLimiter = rateLimit({
  store: new RedisStore({
    sendCommand: (...args) => redis.call(...args),
  }),
  windowMs: 60 * 1000,        // 1-minute window
  max: 100,                    // 100 requests per minute per key
  keyGenerator: (req) => {
    return req.headers['x-api-key'] || req.ip;
  },
  handler: (req, res) => {
    res.status(429).json({
      error: 'Rate limit exceeded',
      retryAfter: Math.ceil(req.rateLimit.resetTime / 1000),
      limit: req.rateLimit.limit,
      remaining: req.rateLimit.remaining
    });
  },
  standardHeaders: true,       // Return RateLimit-* headers
  legacyHeaders: false
});

app.use('/api/', apiKeyLimiter);

CORS Configuration

If your API is consumed by browser-based clients (SPAs, web apps), you need CORS headers. Configure them in Nginx for consistency across all endpoints:

# CORS configuration in Nginx

# Map to handle preflight and actual requests
map $request_method $cors_method {
    OPTIONS 11;
    default 0;
}

server {
    # ... SSL and other config ...

    location /api/ {
        # CORS headers for all responses
        set $cors_origin "";

        # Allow specific origins (not wildcard — more secure)
        if ($http_origin ~* "^https://(www\.yourdomain\.com|app\.yourdomain\.com)$") {
            set $cors_origin $http_origin;
        }

        add_header Access-Control-Allow-Origin $cors_origin always;
        add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-API-Key, X-Request-ID" always;
        add_header Access-Control-Max-Age 86400 always;
        add_header Access-Control-Allow-Credentials true always;

        # Handle preflight requests
        if ($request_method = OPTIONS) {
            return 204;
        }

        proxy_pass http://api_backend;
        # ... other proxy settings
    }
}

For a public API that any origin can access:

# Public API CORS (simpler)
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;

if ($request_method = OPTIONS) {
    return 204;
}

Important: Never use Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true. Browsers reject this combination. If you need credentials (cookies, auth headers), you must specify exact origins.

Authentication Patterns

API Key Authentication

The simplest authentication method. Validate API keys at the Nginx level for efficiency, or at the application level for flexibility.

# Nginx: Block requests without an API key before they reach your app
location /api/ {
    # Require X-API-Key header
    if ($http_x_api_key = "") {
        return 401 '{"error": "API key required", "status": 401}';
    }

    proxy_pass http://api_backend;
    proxy_set_header X-API-Key $http_x_api_key;
}

For application-level API key validation (recommended for most cases):

// Node.js middleware
const authenticateApiKey = async (req, res, next) => {
  const apiKey = req.headers['x-api-key'];

  if (!apiKey) {
    return res.status(401).json({
      error: 'Authentication required',
      message: 'Include your API key in the X-API-Key header'
    });
  }

  // Hash the key and look it up (never store plain-text keys)
  const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
  const keyRecord = await db.query(
    'SELECT id, user_id, permissions, rate_limit FROM api_keys WHERE key_hash = $1 AND revoked_at IS NULL',
    [keyHash]
  );

  if (keyRecord.rows.length === 0) {
    return res.status(401).json({ error: 'Invalid API key' });
  }

  req.apiKey = keyRecord.rows[0];
  next();
};

app.use('/api/', authenticateApiKey);

JWT Validation at Nginx Level

For APIs using JWTs, you can validate tokens at the Nginx level using the ngx_http_auth_jwt_module (Nginx Plus) or validate in your application:

// Node.js JWT validation middleware
const jwt = require('jsonwebtoken');

const authenticateJWT = (req, res, next) => {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({
      error: 'Authentication required',
      message: 'Include a Bearer token in the Authorization header'
    });
  }

  const token = authHeader.split(' ')[1];

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET, {
      algorithms: ['HS256'],
      issuer: 'api.yourdomain.com'
    });
    req.user = decoded;
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired' });
    }
    return res.status(401).json({ error: 'Invalid token' });
  }
};

// Apply to protected routes
app.use('/api/v1/users/', authenticateJWT);
app.use('/api/v1/orders/', authenticateJWT);

// Public routes (no auth required)
app.get('/api/v1/products', listProducts);
app.get('/api/v1/health', healthCheck);

API Logging and Request Tracing

API debugging requires tracing individual requests across your reverse proxy and application. Use request IDs to correlate log entries.

Nginx Access Log for APIs

# Custom log format for API traffic
log_format api_json escape=json
  '{'
    '"timestamp":"$time_iso8601",'
    '"request_id":"$request_id",'
    '"remote_addr":"$remote_addr",'
    '"method":"$request_method",'
    '"uri":"$request_uri",'
    '"status":$status,'
    '"body_bytes_sent":$body_bytes_sent,'
    '"request_time":$request_time,'
    '"upstream_response_time":"$upstream_response_time",'
    '"http_user_agent":"$http_user_agent",'
    '"http_x_api_key":"$http_x_api_key"'
  '}';

server {
    access_log /var/log/nginx/api_access.json api_json;
    # ...
}

Application-Level Structured Logging

// Node.js structured logging middleware
const requestLogger = (req, res, next) => {
  const startTime = process.hrtime.bigint();
  const requestId = req.headers['x-request-id'] || crypto.randomUUID();

  // Attach request ID to response
  res.setHeader('X-Request-ID', requestId);
  req.requestId = requestId;

  // Log when response finishes
  res.on('finish', () => {
    const duration = Number(process.hrtime.bigint() - startTime) / 1e6; // ms

    const logEntry = {
      timestamp: new Date().toISOString(),
      requestId,
      method: req.method,
      path: req.path,
      status: res.statusCode,
      duration: Math.round(duration * 100) / 100,
      userAgent: req.headers['user-agent'],
      apiKey: req.apiKey?.id || null,
      ip: req.ip
    };

    if (res.statusCode >= 500) {
      console.error(JSON.stringify(logEntry));
    } else if (res.statusCode >= 400) {
      console.warn(JSON.stringify(logEntry));
    } else {
      console.log(JSON.stringify(logEntry));
    }
  });

  next();
};

app.use(requestLogger);

For parsing and analyzing logs, see our server logs troubleshooting guide.

Querying Logs by Request ID

# Find a specific request across nginx and application logs
REQUEST_ID="abc123-def456"

# Search Nginx logs
grep "$REQUEST_ID" /var/log/nginx/api_access.json | jq .

# Search application logs (if using PM2)
pm2 logs api --lines 10000 | grep "$REQUEST_ID"

# Search application logs (if using systemd)
journalctl -u api --since "1 hour ago" | grep "$REQUEST_ID"

Health Check Endpoints

Every API should expose health check endpoints. These are used by load balancers, monitoring systems, and deployment tools to determine whether the API is ready to serve traffic.

// Three-tier health check pattern

// Liveness: Is the process running?
app.get('/health/live', (req, res) => {
  res.status(200).json({ status: 'alive' });
});

// Readiness: Can the API handle requests?
app.get('/health/ready', async (req, res) => {
  try {
    // Check database connectivity
    await db.query('SELECT 1');

    // Check Redis connectivity
    await redis.ping();

    res.status(200).json({
      status: 'ready',
      checks: {
        database: 'connected',
        cache: 'connected'
      }
    });
  } catch (err) {
    res.status(503).json({
      status: 'not ready',
      checks: {
        database: err.message.includes('database') ? 'disconnected' : 'connected',
        cache: err.message.includes('redis') ? 'disconnected' : 'connected'
      }
    });
  }
});

// Startup: Has the API finished initializing?
let isStarted = false;
app.get('/health/startup', (req, res) => {
  if (isStarted) {
    res.status(200).json({ status: 'started' });
  } else {
    res.status(503).json({ status: 'starting' });
  }
});

// Mark as started after initialization completes
async function startServer() {
  await db.connect();
  await redis.connect();
  await loadConfiguration();

  app.listen(3000, () => {
    isStarted = true;
    console.log('API server ready on port 3000');
  });
}

startServer();

Configure Nginx to use the health check for upstream monitoring:

# Don't log health check requests (they're noisy)
location /health/ {
    access_log off;
    proxy_pass http://api_backend;
}

API Monitoring with Uptime Kuma

For API monitoring, you need to track more than just "is it up." Response time degradation is an early warning sign that something is wrong, often before outright failure occurs.

Set up Uptime Kuma with API-specific monitors:

# Monitor 1: Liveness (basic uptime)
Type: HTTP(s)
URL: https://api.yourdomain.com/health/live
Interval: 30 seconds
Expected status: 200

# Monitor 2: Readiness (dependency health)
Type: HTTP(s)
URL: https://api.yourdomain.com/health/ready
Interval: 60 seconds
Expected status: 200
Alert if: status is not 200

# Monitor 3: Response time monitoring
Type: HTTP(s)
URL: https://api.yourdomain.com/api/v1/products?limit=1
Interval: 60 seconds
Expected status: 200
Headers: X-API-Key: your-monitoring-api-key
Alert if: response time > 500ms

Set up alerts for response time degradation, not just downtime. A 200 OK response that takes 2 seconds is worse than a 503 that triggers an immediate alert — because the slow response silently degrades user experience while the 503 is obvious.

Capacity Planning: Requests Per Second by VPS Configuration

API throughput depends on your application's complexity, database query patterns, and response payload size. Here are realistic benchmarks for a typical CRUD REST API (JSON responses, database-backed):

VPS Configuration Simple Reads (cached) DB Reads DB Writes Suitable For
1 vCPU / 2 GB RAM 500-1,000 req/s 100-300 req/s 50-150 req/s Internal APIs, MVPs
2 vCPU / 4 GB RAM 1,000-2,000 req/s 300-700 req/s 150-400 req/s Production APIs, mobile backends
4 vCPU / 8 GB RAM 2,000-5,000 req/s 700-1,500 req/s 400-800 req/s High-traffic APIs
8 vCPU / 16 GB RAM 5,000-10,000 req/s 1,500-3,000 req/s 800-1,500 req/s Heavy production workloads

Benchmark your specific API to get accurate numbers:

# Install wrk (HTTP benchmarking tool)
sudo apt install -y wrk

# Benchmark a read endpoint
wrk -t4 -c100 -d30s -H "X-API-Key: test-key" \
  https://api.yourdomain.com/api/v1/products

# Output:
#   Running 30s test @ https://api.yourdomain.com/api/v1/products
#     4 threads and 100 connections
#     Thread Stats   Avg      Stdev     Max   +/- Stdev
#       Latency    12.34ms    5.67ms  89.12ms   78.90%
#       Req/Sec   812.45    123.67     1.23k    72.34%
#   Latency Distribution
#      50%   11.23ms    ← p50
#      75%   14.56ms
#      90%   18.90ms
#      99%   34.56ms    ← p99 (this is the number that matters)
#   97234 requests in 30.01s, 45.67MB read
# Requests/sec:   3240.78

# Benchmark a write endpoint
wrk -t4 -c50 -d30s -s post.lua \
  https://api.yourdomain.com/api/v1/orders

Create the wrk script for POST requests:

-- post.lua
wrk.method = "POST"
wrk.headers["Content-Type"] = "application/json"
wrk.headers["X-API-Key"] = "test-key"
wrk.body = '{"product_id": 1, "quantity": 1}'

A 200ms API response time means your mobile app feels sluggish. For consistent p99 latency under load, MassiveGRID Cloud VDS delivers dedicated CPU that eliminates noisy-neighbor effects — starting at $19.80/mo.

API Versioning in Nginx

When your API evolves, version it properly. Nginx can route different API versions to different application instances:

# Route API versions to different backends
upstream api_v1 {
    server 127.0.0.1:3001;
    keepalive 16;
}

upstream api_v2 {
    server 127.0.0.1:3002;
    keepalive 16;
}

server {
    # ... SSL config ...

    location /api/v1/ {
        proxy_pass http://api_v1;
        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 /api/v2/ {
        proxy_pass http://api_v2;
        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;
    }

    # Redirect unversioned requests to latest version
    location /api/ {
        return 301 /api/v2$request_uri;
    }
}

Response Compression

JSON compresses extremely well. Enable gzip for API responses:

# /etc/nginx/conf.d/gzip.conf
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 4;               # Balance between CPU and compression
gzip_min_length 256;             # Don't compress tiny responses
gzip_types
    application/json
    application/javascript
    text/plain
    text/css
    application/xml;

Verify compression is working:

# Check if response is compressed
curl -s -H "Accept-Encoding: gzip" -H "X-API-Key: test-key" \
  -o /dev/null -w "Size: %{size_download} bytes\n" \
  https://api.yourdomain.com/api/v1/products

# Compare without compression
curl -s -H "X-API-Key: test-key" \
  -o /dev/null -w "Size: %{size_download} bytes\n" \
  https://api.yourdomain.com/api/v1/products

Request Validation and Error Handling

A well-designed API returns consistent, predictable error responses. Standardize your error format:

// Standardized error response format
const errorHandler = (err, req, res, next) => {
  // Log the full error internally
  console.error(JSON.stringify({
    timestamp: new Date().toISOString(),
    requestId: req.requestId,
    error: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method
  }));

  // Return sanitized error to client
  const status = err.status || 500;
  const response = {
    error: {
      code: err.code || 'INTERNAL_ERROR',
      message: status === 500
        ? 'An internal error occurred'
        : err.message,
      requestId: req.requestId
    }
  };

  // Include validation details for 400 errors
  if (status === 400 && err.details) {
    response.error.details = err.details;
  }

  res.status(status).json(response);
};

// Input validation middleware
const validateBody = (schema) => (req, res, next) => {
  const errors = [];

  for (const [field, rules] of Object.entries(schema)) {
    const value = req.body[field];

    if (rules.required && (value === undefined || value === null)) {
      errors.push({ field, message: `${field} is required` });
      continue;
    }

    if (value !== undefined) {
      if (rules.type && typeof value !== rules.type) {
        errors.push({ field, message: `${field} must be a ${rules.type}` });
      }
      if (rules.min !== undefined && value < rules.min) {
        errors.push({ field, message: `${field} must be at least ${rules.min}` });
      }
      if (rules.maxLength !== undefined && value.length > rules.maxLength) {
        errors.push({ field, message: `${field} must be at most ${rules.maxLength} characters` });
      }
    }
  }

  if (errors.length > 0) {
    const err = new Error('Validation failed');
    err.status = 400;
    err.code = 'VALIDATION_ERROR';
    err.details = errors;
    return next(err);
  }

  next();
};

// Usage
app.post('/api/v1/orders', validateBody({
  product_id: { required: true, type: 'number', min: 1 },
  quantity: { required: true, type: 'number', min: 1 },
  notes: { type: 'string', maxLength: 500 }
}), createOrder);

app.use(errorHandler);

API Security Checklist

Before making your API publicly accessible, verify every item on this checklist:

# 1. HTTPS only — no HTTP access
curl -I http://api.yourdomain.com
# Should return 301 redirect to HTTPS

# 2. Security headers present
curl -I https://api.yourdomain.com/api/v1/health/live
# X-Content-Type-Options: nosniff
# X-Frame-Options: DENY
# Strict-Transport-Security: max-age=31536000

# 3. Rate limiting active
for i in $(seq 1 50); do
  curl -s -o /dev/null -w "%{http_code}\n" \
    https://api.yourdomain.com/api/v1/products
done
# Should see 429 responses after hitting the limit

# 4. Error responses don't leak internals
curl -s https://api.yourdomain.com/api/v1/nonexistent
# Should return JSON error, not stack trace or server info

# 5. Database is not directly accessible
nmap -p 5432 your-vps-ip
# Should show: filtered or closed

# 6. Sensitive endpoints require authentication
curl -s https://api.yourdomain.com/api/v1/users
# Should return 401, not data

For comprehensive server-level security, follow our Ubuntu VPS security hardening guide.

Deployment Workflow for API Updates

A zero-downtime deployment process for your API:

#!/bin/bash
# deploy-api.sh — Zero-downtime API deployment

set -euo pipefail

APP_DIR="/home/deploy/api"
BACKUP_DIR="/home/deploy/api-backup"

echo "=== Starting API deployment ==="

# 1. Pull latest code
cd "$APP_DIR"
git fetch origin main
git reset --hard origin/main

# 2. Install dependencies
npm ci --production

# 3. Run database migrations
npm run db:migrate

# 4. Restart with zero downtime (PM2)
pm2 reload api --update-env

# 5. Wait for the new process to be ready
echo "Waiting for health check..."
for i in $(seq 1 30); do
  if curl -sf http://localhost:3000/health/ready > /dev/null 2>&1; then
    echo "API is ready after ${i}s"
    break
  fi
  sleep 1
done

# 6. Verify
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/health/ready)
if [ "$STATUS" = "200" ]; then
  echo "=== Deployment successful ==="
else
  echo "=== Deployment FAILED — rolling back ==="
  cd "$APP_DIR"
  git reset --hard HEAD~1
  npm ci --production
  pm2 reload api --update-env
  exit 1
fi

Prefer managed API infrastructure? MassiveGRID Managed Dedicated Cloud Servers handle server management, security hardening, SSL renewals, and performance optimization — you focus on building your API.

Summary

Hosting a REST API on a VPS gives you complete control over performance, security, and cost. The critical components are:

  1. Process manager (PM2, Gunicorn, systemd) keeps your API running
  2. Nginx reverse proxy handles SSL, rate limiting, CORS, and JSON error responses
  3. Rate limiting at both Nginx and application levels protects against abuse
  4. Authentication (API keys or JWT) controls access
  5. Structured logging with request IDs enables request tracing
  6. Health checks enable automated monitoring and deployment verification
  7. Monitoring tracks response time, not just uptime

Start with a 2 vCPU / 4 GB RAM VPS, benchmark your actual throughput, and scale CPU independently as your traffic grows. Most APIs are CPU-bound — adding more vCPU cores scales linearly until you hit database or I/O bottlenecks.