To host an Express API in South Africa, you need a Node.js host that keeps your process running, gives you environment variables and a database close to the app, and routes HTTPS traffic to your port. Just as important is preparing the API itself: read the port from the environment, lock down CORS, add a health check, handle errors centrally and shut down cleanly on redeploys. This guide gives you a production-ready Express 5 structure and then walks through deploying it.
A structure that scales
Separate the app (routes and middleware) from the server (listening and shutdown). It keeps tests simple, because tests can import the app without opening a port.
my-api/
src/
app.js <- Express app: middleware, routes, error handler
server.js <- starts listening, handles shutdown
db.js <- database pool
routes/
orders.js
package.json
{
"name": "my-api",
"type": "module",
"scripts": {
"dev": "node --watch --env-file=.env src/server.js",
"start": "node src/server.js"
},
"engines": { "node": ">=24" },
"dependencies": {
"cors": "^2.8.5",
"express": "^5.1.0",
"helmet": "^8.1.0",
"mysql2": "^3.14.0"
}
}
Express 5 is now the default release on npm. Its most useful change for APIs: if an async route handler throws or returns a rejected promise, the error is passed to your error handler automatically, so you no longer need wrapper functions.
The app
// src/app.js
import express from "express";
import cors from "cors";
import helmet from "helmet";
import { pool } from "./db.js";
import orders from "./routes/orders.js";
export const app = express();
app.set("trust proxy", 1); // behind the host's HTTPS proxy
app.use(helmet());
app.use(express.json({ limit: "1mb" }));
const allowed = (process.env.CORS_ORIGINS ?? "").split(",").filter(Boolean);
app.use(
cors({
origin: (origin, callback) => {
// allow server-to-server calls (no Origin header) and listed origins
if (!origin || allowed.includes(origin)) return callback(null, true);
callback(null, false);
},
credentials: true,
})
);
app.get("/healthz", async (req, res) => {
await pool.query("SELECT 1");
res.json({ status: "ok" });
});
app.use("/orders", orders);
app.use((req, res) => {
res.status(404).json({ error: "Not found" });
});
app.use((err, req, res, next) => {
console.error(err);
res.status(err.status ?? 500).json({ error: "Something went wrong" });
});
CORS, properly
CORS only matters for browsers: it controls which websites may call your API from JavaScript. Two rules:
- List origins explicitly (
https://www.example.co.za,https://admin.example.co.za) in an environment variable. Do not useorigin: "*"together with cookies or auth headers; browsers reject credentialed requests to a wildcard origin anyway. - CORS is not security. A script or server can call your API regardless of CORS. Authentication and authorisation still have to happen on every route. The MDN CORS guide explains the browser side in detail.
The health check
/healthz returns 200 only if the app can reach its database, which makes it useful for uptime monitoring. If the query throws, Express 5 forwards the error to the error handler, which returns a 500.
The database pool
// src/db.js
import mysql from "mysql2/promise";
export const pool = mysql.createPool({
uri: process.env.DATABASE_URL,
connectionLimit: 10,
waitForConnections: true,
});
Use a pool rather than a single connection, and always use placeholders to avoid SQL injection:
// src/routes/orders.js
import { Router } from "express";
import { pool } from "../db.js";
const router = Router();
router.get("/:id", async (req, res) => {
const [rows] = await pool.query("SELECT id, status, total FROM orders WHERE id = ?", [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: "Not found" });
res.json(rows[0]);
});
export default router;
If you prefer an ORM with migrations, see connecting Node.js to MySQL with Prisma.
The server and graceful shutdown
// src/server.js
import { app } from "./app.js";
import { pool } from "./db.js";
for (const name of ["DATABASE_URL", "CORS_ORIGINS"]) {
if (!process.env[name]) {
console.error(`Missing environment variable ${name}`);
process.exit(1);
}
}
const port = Number(process.env.PORT) || 3000;
const server = app.listen(port, () => console.log(`API listening on ${port}`));
process.on("SIGTERM", () => {
server.close(async () => {
await pool.end();
process.exit(0);
});
setTimeout(() => process.exit(1), 10_000).unref();
});
On a redeploy the platform sends SIGTERM. Closing the server lets in-flight requests finish, and closing the pool releases database connections cleanly.
Environment variables for an API
| Variable | Example | Purpose |
|---|---|---|
NODE_ENV |
production |
Turns off development behaviour |
PORT |
set by the host | Port to listen on |
DATABASE_URL |
mysql://user:pass@host:3306/db |
Database connection |
CORS_ORIGINS |
https://www.example.co.za |
Browser origins allowed to call the API |
JWT_SECRET / SESSION_SECRET |
long random string | Signing tokens or sessions |
Never commit these. Keep a .env.example with dummy values so teammates know what to set.
Hardening checklist
- Rate-limit login and other sensitive routes (the
express-rate-limitpackage is a common choice). - Validate request bodies with a schema library before touching the database.
- Return generic error messages; log the detail on the server only.
- Keep dependencies updated and run
npm auditin CI. - Log requests and errors to stdout so the platform captures them.
The Node.js security checklist goes further.
Why host your API in South Africa
If your web app, mobile app users and database are in South Africa, a local API keeps every request and every query on a short path. It also keeps personal information in the country, which simplifies your POPIA position, and you pay in rand with a local VAT invoice.
Deploying on NewHost
- Push the repository to GitHub or GitLab.
- Create an app in the dashboard, connect the repository and choose the branch.
- Install command
npm ci, no build command (unless you use TypeScript), start commandnpm start. - Create a managed MySQL database and add its connection string as
DATABASE_URL, plus your other environment variables (stored encrypted). - Deploy, add your API domain (for example
api.example.co.za) and let the free Let's Encrypt certificate issue.
Every push to the branch redeploys automatically. Plans start at R99/month (excl. VAT) for one app with one MySQL database. See Node.js hosting for more.
Frequently asked questions
Should I use Express 4 or Express 5 for a new API?
Use Express 5. It is the current release, handles rejected promises from async handlers automatically and has safer path matching. Check the migration notes if you upgrade an existing app, because some route path syntax changed.
Why do I get CORS errors only in production?
Usually the production front-end origin is missing from your allowed list, or it differs slightly (www vs no www, http vs https). Origins must match exactly, including the scheme.
Do I need Nginx in front of Express?
On managed hosting, no. The platform's proxy handles HTTPS and routes traffic to your app. On a VPS, a reverse proxy like Nginx is the usual way to terminate SSL and forward requests.
Can I run background jobs alongside my Express API?
For recurring work, use scheduled tasks rather than timers inside the API process, so jobs keep running correctly across restarts and redeploys.
Ready to put your Express API on South African servers? Start with NewHost Node.js hosting and a managed MySQL database.