Learning objectives
After reading this article you will be able to:
- Review the items that separate a test API from a production API
- Implement a health check, graceful shutdown, standard errors and rate limiting in Node.js
- Verify each checklist item with curl
On this page
- What does the checklist cover?
- How do you configure the API without secrets in code?
- What does a minimal API with the checklist applied look like?
- Why standardize errors?
- How do you stop the API without dropping in-flight requests?
- How do you verify everything works?
- What are the most common mistakes?
- How to do this on Square Cloud
- FAQs
An API that works on your machine is not ready for real traffic. In production you get malformed requests, clients that retry nonstop, deploys in the middle of a request and errors nobody sees without logs. Each item below fixes one of those failures.
The code uses Express 5 on the current Active LTS of Node.js, but the checklist applies to any language. If you have not decided where the API will run yet, read how to host an API first.
What does the checklist cover?#
- Configuration from environment variables, failing fast when something is missing.
- HTTPS and a health check endpoint.
- Graceful shutdown on SIGTERM.
- Standard errors that leak no internals.
- Authentication and input validation.
- Rate limiting and a request body size limit.
- A restrictive CORS policy.
- Structured logs.
- Timeouts.
- A final check with
curl.
Items 5 to 7 cover many of the risks the OWASP API Security Top 10 (2023) lists, such as broken authentication, unrestricted resource consumption and security misconfiguration. For the infrastructure side, see the deploy security checklist.
How do you configure the API without secrets in code?#
Read everything from environment variables and stop the process at startup if a required value is missing. That turns a silent production bug (an undefined token that accepts anything) into an obvious failure on the very first start. Never commit secrets to the repository. If the concept is new to you, read what environment variables are.
Also set NODE_ENV=production. With it, Express produces less verbose error messages and caches templates, which its documentation ties to a performance gain.
What does a minimal API with the checklist applied look like?#
Install the dependency and create server.mjs:
npm install express@5import { timingSafeEqual } from "node:crypto";
import { STATUS_CODES } from "node:http";
import express from "express";
const { API_TOKEN, ALLOWED_ORIGIN } = process.env;
if (!API_TOKEN) {
console.error("API_TOKEN is missing");
process.exit(1);
}
const PORT = Number(process.env.PORT ?? 3000);
const problem = (res, status, title, detail) =>
res.status(status).type("application/problem+json").json({ type: "about:blank", title, status, detail });
const app = express();
app.disable("x-powered-by");
app.set("trust proxy", 1);
app.use(express.json({ limit: "100kb" }));
app.use((req, res, next) => {
const start = Date.now();
res.on("finish", () =>
console.log(JSON.stringify({ method: req.method, path: req.path, status: res.statusCode, ms: Date.now() - start })),
);
next();
});
app.use((req, res, next) => {
if (ALLOWED_ORIGIN && req.headers.origin === ALLOWED_ORIGIN) {
res.set("Access-Control-Allow-Origin", ALLOWED_ORIGIN);
res.set("Access-Control-Allow-Headers", "Authorization, Content-Type");
res.set("Vary", "Origin");
}
if (req.method === "OPTIONS") return res.sendStatus(204);
next();
});
const hits = new Map();
setInterval(() => hits.clear(), 60_000).unref();
app.use((req, res, next) => {
const n = (hits.get(req.ip) ?? 0) + 1;
hits.set(req.ip, n);
if (n > 100) {
res.set("Retry-After", "60");
return problem(res, 429, "Too Many Requests", "Limit of 100 requests per minute.");
}
next();
});
app.get("/health", (_req, res) => res.json({ status: "ok" }));
const safeEqual = (a, b) => {
const x = Buffer.from(a);
const y = Buffer.from(b);
return x.length === y.length && timingSafeEqual(x, y);
};
app.use((req, res, next) => {
const token = (req.headers.authorization ?? "").replace(/^Bearer /, "");
if (!safeEqual(token, API_TOKEN)) return problem(res, 401, "Unauthorized", "Missing or invalid token.");
next();
});
app.post("/items", async (req, res) => {
const { name } = req.body ?? {};
if (typeof name !== "string" || name.length === 0 || name.length > 100) {
return problem(res, 400, "Bad Request", "name must be a string of 1 to 100 characters.");
}
res.status(201).json({ name });
});
app.use((err, _req, res, _next) => {
if (err.status >= 400 && err.status < 500) return problem(res, err.status, STATUS_CODES[err.status], "Invalid request.");
console.error(err);
problem(res, 500, "Internal Server Error", "Unexpected error.");
});
const server = app.listen(PORT, "0.0.0.0", () => console.log(`API listening on port ${PORT}`));
server.requestTimeout = 30_000;
process.on("SIGTERM", () => {
server.close(() => process.exit(0));
setTimeout(() => {
server.closeAllConnections();
process.exit(1);
}, 10_000).unref();
});What each block does:
- Config: without
API_TOKENthe process exits immediately.ALLOWED_ORIGINis optional. trust proxy: behind a reverse proxy,req.ipwould be the proxy's IP. Withtrust proxyset to 1, Express uses the client IP the proxy reports. Adjust the value to the number of proxies in front of the API.- Limited body:
express.json({ limit: "100kb" })rejects large bodies before they eat memory. - JSON logs: one line per request, easy to filter. Never log tokens, passwords or the whole body.
- CORS: only the configured origin gets the headers. Avoid
*on an authenticated API.Vary: Originstops a cache from serving the wrong response. - Rate limit: 100 requests per minute per IP, in memory. This works for a single instance; with several instances, keep the counters in Redis.
- Authentication: constant-time comparison with
timingSafeEqual, which requires buffers of the same size (the size check reveals only the token's length)./healthsits before it so a monitor works without credentials. - Validation: never trust what the client sends. Check type and length; for larger schemas, use a validation library.
- Error handler: in Express 5, errors thrown in
asyncfunctions reach the error middleware automatically, with notry/catchin every handler.
Why standardize errors?#
The application/problem+json format from RFC 9457 defines common fields: type, title, status, detail and instance. With them, clients and SDKs handle any error from your API the same way, and you never return a stack trace: the full error goes to the log and the client gets only the summary.
For a 429, send a Retry-After header with the number of seconds to wait. Well-behaved clients honor it instead of hammering the API.
How do you stop the API without dropping in-flight requests?#
On a deploy or restart, the process usually receives SIGTERM. Unhandled, it dies in the middle of requests. In the code above, server.close() stops accepting new connections and closes idle ones, letting active requests finish. After 10 seconds, closeAllConnections() forces the end. Both methods exist in every Node.js version that is still supported.
If your API uses a database or a queue, close those connections inside the close() callback.
Timeouts. Set a limit for the whole request (server.requestTimeout) and a separate timeout on every call your API makes to other services. Without them, a slow upstream holds connections open until the API runs out of resources. For outgoing calls, fetch(url, { signal: AbortSignal.timeout(5000) }) aborts after 5 seconds.
How do you verify everything works?#
Run API_TOKEN=secret node server.mjs and test each item:
curl -i localhost:3000/health
curl -i localhost:3000/items -X POST -H "Content-Type: application/json" -d '{"name":"a"}'
curl -i localhost:3000/items -X POST -H "Authorization: Bearer secret" -H "Content-Type: application/json" -d '{"name":""}'
curl -i localhost:3000/items -X POST -H "Authorization: Bearer secret" -H "Content-Type: application/json" -d '{"name":"pen"}'Expected: 200, 401, 400 and 201. For the rate limit, repeat a call more than 100 times within a minute and look for a 429 with Retry-After. For shutdown, run kill -TERM <pid> while a slow request is in flight and confirm it finishes before the process exits.
What are the most common mistakes?#
- A health check that always returns 200: if the API depends on a database, check the connection in
/healthso it reflects reality. - CORS with
*and credentials: the browser blocks it, and opening everything "to make it work" exposes the API. - Per-IP rate limiting behind a proxy without
trust proxy: every client looks like the same IP and they share one limit. - Logging the request body: passwords and tokens end up in plain text in your logs.
- A database backup nobody has tested: test the restore, not only the backup itself.
- No external monitor: with nobody watching
/health, you learn about outages from users.
How to do this on Square Cloud#
On Square Cloud you deploy the API with a ZIP, the CLI, GitHub or the VS Code extension, and the platform already covers part of the checklist. The rest stays in your code. Item by item:
- HTTPS: every web application gets a
*.squareweb.appsubdomain with automatic TLS, on every plan. A custom domain starts at Standard. - Configuration:
API_TOKENandALLOWED_ORIGINgo in as environment variables in the dashboard, the API or the CLI and apply on the next restart, with no rebuild. The platform setsPORT, andserver.mjslistens on0.0.0.0at that port. - Availability: with automatic restart on (the default for dashboard deploys), the API comes back by itself when the process exits with an error. Running out of memory, by contrast, stops the app until you raise its RAM and start it again.
- Logs and metrics: the JSON log lines stream in real time in the dashboard, the CLI and VS Code, and CPU, RAM and network metrics are kept for 24 hours. Pro and Enterprise add p50/p95/p99 latency and HTTP logs.
- Protection: every plan includes a WAF and L3/L4/L7 DDoS protection, but per-client rate limiting is still the API's job.
- Several instances: load balancing spreads the domain across applications with no sticky sessions and no active health check. Move the rate limit counters to Redis and keep an external monitor on
/health. Load balancing and Redis hosting start at Standard, from R$44.99 per month. - Database: hosted databases get an automatic daily snapshot, and a snapshot restores into a new database, where you test the restore without touching production.
There is no autoscaling: for more capacity, you raise the RAM or add applications to the load balancer. See the plans on API hosting and use the official SDKs to automate deploys, restarts and environment variables.
API hosting
APIs in always-on containers, with no cold starts
FAQs
What must an API have before going to production?
Configuration through environment variables, HTTPS, authentication, input validation, rate limiting, error handling that leaks no internals, logs and a health check endpoint.
Do I need a health check on a small API?
Yes. A simple endpoint that returns 200 lets an external monitor notice when the API is down, instead of waiting for a user to complain.
Which status should I return when a client exceeds the rate limit?
429 Too Many Requests, with a Retry-After header telling the client how many seconds to wait before trying again.
Can I send the server error message back to the client?
No. Stack traces and internal messages reveal structure and dependencies. Log the full error and return only a title and a status to the client.
More in APIs
See all 15 articles- How to host an APIHosting an API means running its HTTP server around the clock at a public address with HTTPS. Step-by-step with Node.js and Python, testing and common errors.
- How to host a Node.js appTo host a Node.js app, prepare package.json, read the port and secrets from the environment, pick an LTS version and run it with node. Step-by-step guide.
- How to host a FastAPI, Flask or Django appTo host a FastAPI, Flask or Django app, run it with a production server (Uvicorn or Gunicorn) on 0.0.0.0. Commands, Django settings and common errors included.
- How to host a Python app or scriptTo host a Python app or script, pin the version, isolate dependencies in a venv, read secrets from the environment and handle SIGTERM. Step-by-step guide.