Skip to content
NewHost
Menu

Express API Hosting in South Africa - Structure, CORS and Deploy

Express API hosting in South Africa - a production-ready Express 5 structure, CORS, health checks, environment variables, MySQL and deploying from Git.

By NewHost team · · 5 min read

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 use origin: "*" 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-limit package 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 audit in 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

  1. Push the repository to GitHub or GitLab.
  2. Create an app in the dashboard, connect the repository and choose the branch.
  3. Install command npm ci, no build command (unless you use TypeScript), start command npm start.
  4. Create a managed MySQL database and add its connection string as DATABASE_URL, plus your other environment variables (stored encrypted).
  5. 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.

Related guides

Ready to launch on NewHost?

Choose a plan and go live today, or tell us what you need and we'll recommend the right setup.