Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

How to Run an Express Server, From First Request to Production

Sean

Platform Writer

Sep 06, 2026
9 min read

Running an Express server is four lines of JavaScript and one node command. The part worth reading past the first section is everything the four-line version leaves out: reading the port from the environment, binding to the right interface, shutting down cleanly, and not putting your database password in the repository.

How to Run an Express Server, From First Request to Production

Every Express tutorial ends at hello world, and every production incident starts just past it. The gap between a server that runs on your laptop and one that behaves on a host is not large, but it is made entirely of things that are invisible locally — a hardcoded port, a bind address that works on your machine and not in a container, a process that gets killed mid-request on every deploy. This covers both halves.

Table of contents

The minimum that works

Install Express and create a file:

npm init -y
npm install express
const express = require('express');

const app = express();
const port = 3000;

app.get('/', (req, res) => {
  res.send('Hello World!');
});

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`);
});

Run it:

node app.js

Open http://localhost:3000 and it responds. If you prefer ES module syntax, add "type": "module" to package.json and use import express from 'express' instead — the rest is identical.

That is genuinely the whole local story. Two of those lines, though, are wrong for anywhere that is not your laptop.

The two lines that break in production

The hardcoded port. Every host assigns a port and expects your process to use it, usually through the PORT environment variable. A hardcoded 3000 means the platform routes traffic to a port nothing is listening on, and the deploy fails a health check with no obvious cause.

The bind address. app.listen(port) binds to all interfaces by default, which is right for a container. But a lot of production examples specify 127.0.0.1, which is loopback-only — reachable from inside the container and from nowhere else. In a container that is the same as not listening at all, and it produces exactly the same confusing symptom as the wrong port.

Both fixed:

const port = process.env.PORT || 3000;

app.listen(port, '0.0.0.0', () => {
  console.log(`listening on ${port}`);
});

Keep the || 3000 fallback so local development still works with no environment set up. This is one of the few places where a default is unambiguously a good idea.

Adding a health check before you need one

Most platforms want an endpoint that returns 200 when the service is ready. Add it early — it costs three lines and it is the difference between a deploy that succeeds and a deploy that times out for reasons you then have to investigate.

app.get('/healthz', (req, res) => {
  res.status(200).json({ status: 'ok' });
});

Keep it genuinely cheap. The temptation is to have the health check verify the database connection, which sounds thorough and causes cascading failures: a brief database blip marks every instance unhealthy, the platform restarts them all, and the restart storm is now the outage. Check that the process is alive and serving. If you want a deeper check, expose it on a separate endpoint that your monitoring calls and the orchestrator does not.

Configuration and secrets

Anything that differs between your laptop and production, or that would be embarrassing in a public repository, comes from the environment:

const config = {
  port: process.env.PORT || 3000,
  databaseUrl: process.env.DATABASE_URL,
  nodeEnv: process.env.NODE_ENV || 'development',
};

if (!config.databaseUrl) {
  console.error('DATABASE_URL is not set');
  process.exit(1);
}

The explicit check matters more than it looks. Without it, a missing database URL surfaces as a connection error on the first request that touches the database — possibly hours after the deploy, possibly to a user. Failing at startup means the deploy fails, the health check never passes, and the previous version keeps serving. That is the behaviour you want.

Locally, a .env file loaded by dotenv is fine, provided the file is in .gitignore. In production the values come from the platform’s environment-variable configuration, and the .env file should not exist.

Shutting down without dropping requests

This is the piece almost every tutorial skips and every deployment needs. When a platform redeploys, it sends SIGTERM and waits a short grace period before sending SIGKILL. A server that ignores SIGTERM gets killed mid-request, and every in-flight request becomes a 502 for a real user.

const server = app.listen(port, '0.0.0.0');

function shutdown(signal) {
  console.log(`${signal} received, closing server`);
  server.close(() => {
    console.log('closed remaining connections');
    process.exit(0);
  });
  setTimeout(() => process.exit(1), 10000).unref();
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

server.close() stops accepting new connections and waits for in-flight requests to finish. The timeout is the backstop for a request that never completes — without it, one stuck connection keeps the process alive until the platform kills it, which defeats the purpose. .unref() stops that timer from keeping the process alive on its own.

Ten to fifteen seconds is a reasonable grace period, and it should be shorter than whatever your platform waits before SIGKILL.

Errors, logs and the middleware order

Express middleware runs in the order you register it, and two pieces belong at the end, after all your routes.

// 404 - nothing above matched
app.use((req, res) => {
  res.status(404).json({ error: 'Not found' });
});

// Error handler - four arguments, always last
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Internal server error' });
});

The error handler is identified by having four parameters. Write three and Express treats it as ordinary middleware, it never receives errors, and you get the default HTML error page in production wondering why your handler did nothing.

Note that the response body does not include the stack trace. Log it, do not send it — an error page that leaks file paths and library versions is a small gift to anyone probing your service.

For logging, write structured JSON to stdout and let the platform collect it. Do not write log files inside the container; they are unreadable without an interactive shell and they disappear on redeploy.

Deploying it

With the above in place, deployment is undramatic. Declare the start command and the Node version, and let the platform build from the repository:

{
  "scripts": {
    "start": "node app.js"
  },
  "engines": {
    "node": ">=20"
  }
}

Pin the Node version explicitly. Relying on the platform default means your runtime changes when the platform updates it, which is a class of surprise nobody enjoys diagnosing.

On RunxBuild this is a Node service deployed from your GitHub repository: build logs, a live route, environment variables for the configuration above, runtime logs beside the deploy logs, custom domains with certificates handled, and rollback to the previous deploy — see Node services on RunxBuild. If the app needs a database, managed Postgres or MySQL sits next to it with backups, connection limits and private networking. The general plan ladder starts at $4 on Dev and $6 on Basic, with 1GB of RAM at $13 on BasicMini, and autoscaling between a floor and ceiling plan for uneven traffic.

One thing worth knowing about Express specifically: it is single-threaded per process. One instance uses one core no matter how many you pay for. Scale by running more instances behind the platform’s load balancing rather than by buying a bigger single box, unless the memory is what you actually need.

How this fits the rest of the stack

The four-line Express server is real and it works. Turning it into something you can deploy is six small changes: read the port from the environment, bind to all interfaces, add a cheap health check, validate configuration at startup, handle SIGTERM so deploys do not drop requests, and put the error handler last with four arguments. None of them takes long, and all of them are much easier to add now than during an incident. To price the service, the database and the bandwidth before you deploy, the RunxBuild hosting calculator lists them as separate line items.

Useful related references:

FAQ

How do I start an Express server?

Install Express with npm install express, create a file that requires it, defines at least one route, and calls app.listen with a port, then run it with node app.js. In production read the port from process.env.PORT rather than hardcoding it.

Why does my Express app work locally but not when deployed?

Almost always the port or the bind address. A hardcoded port means the platform routes traffic somewhere nothing is listening, and binding to 127.0.0.1 makes the server reachable only from inside the container. Use process.env.PORT and bind to 0.0.0.0.

What port should an Express server listen on?

Whatever the platform assigns through the PORT environment variable, with a local fallback such as 3000. Hardcoding a port is the most common reason a deploy passes the build step and then fails its health check.

How do I handle graceful shutdown in Express?

Listen for SIGTERM, call server.close() to stop accepting new connections while in-flight requests finish, and set a timeout as a backstop in case one never does. Without this, every redeploy kills active requests and users see 502s.

Why is my Express error handler not being called?

It probably has three parameters instead of four. Express identifies error-handling middleware by its arity, so a handler written as (req, res, next) is treated as ordinary middleware and never receives errors. It also has to be registered after all your routes.

#run express server#express js server#node express tutorial#express deployment#expressjs production