JavaScript DevOps Node.js

Building RESTful APIs with Node.js and Express

Most Node.js API guides stop at writing the routes. This one covers what actually needs to be true before a Node.js service is ready to run in a container, behind a load balancer, in a production environment — from the perspective of the engineer deploying it.

DA

Damilare Adekunle

· 5 min read

0 Comments

Short link: https://ddadekunle.com/p/3

Building RESTful APIs with Node.js and Express

Most Node.js API tutorials end when the server starts listening on a port. That's where the DevOps work begins.

I've deployed enough containerised services to know the gap between "it works locally" and "it works reliably in production" is almost always the same set of missing pieces. Here's what a Node.js API actually needs before it's ready for ECS, a load balancer, and a CI/CD pipeline.

Project structure that survives beyond one developer

The folder structure you pick early tends to stick. A structure that separates concerns clearly makes the application easier to test, easier to containerise, and easier for another engineer to navigate:

src/
??? routes/       # Route definitions only — no business logic
??? controllers/  # Request handlers, input validation
??? services/     # Business logic, external API calls
??? middleware/   # Auth, rate limiting, error handling
??? config/       # Configuration loaded from env vars
??? app.js        # Express app setup, middleware registration

app.js exports the configured Express app. A separate server.js imports it and calls .listen(). This separation matters for testing — you can import the app without starting an actual HTTP server — and it matters for graceful shutdown, which I'll get to below.

Configuration belongs in the environment, not the code

No hardcoded values. No if (env === 'production') blocks in application code. Configuration that changes between environments — database URLs, API keys, feature flags, service endpoints — should come from environment variables, read at startup through a config module:

// src/config/index.js
module.exports = {
  port: parseInt(process.env.PORT, 10) || 3000,
  db: {
    url: process.env.DATABASE_URL,
  },
  apiKey: process.env.EXTERNAL_API_KEY,
};

Fail fast on missing required config. If DATABASE_URL isn't set and your application can't work without it, throw an error at startup — not when the first request hits the database handler:

if (!process.env.DATABASE_URL) {
  throw new Error('DATABASE_URL is required');
}

This surfaces misconfiguration during deployment, not during user traffic. In an ECS context, a task that fails at startup triggers a health check failure and rolls back automatically — which is exactly what should happen.

The health endpoint your load balancer actually needs

Every service running behind a load balancer needs a health check endpoint. This isn't optional. ECS won't route traffic to a task until the health check passes, and it will pull a task from rotation if it stops passing.

The health endpoint should reflect the real health of the service, not just "the process is running":

app.get('/health', async (req, res) => {
  try {
    await db.query('SELECT 1'); // verify database connectivity
    res.status(200).json({ status: 'ok' });
  } catch (err) {
    res.status(503).json({ status: 'error', detail: err.message });
  }
});

A health check that returns 200 regardless of application state is useless. If the database is unreachable, the service is unhealthy and the load balancer should know. A 503 response pulls the task out of rotation; a 200 keeps it there and lets broken requests through.

Configure the health check in your ECS task definition or load balancer target group with a reasonable interval and threshold. For most services, checking every 30 seconds with 2 consecutive failures before marking unhealthy is a sensible default.

Structured logging

console.log('something happened') is not production logging. When your service is producing hundreds of log lines per second in CloudWatch, free-text logs are unsearchable and unactionable.

Structured JSON logging — one JSON object per log entry — means every log line is filterable by field:

const logger = {
  info: (message, meta = {}) => {
    console.log(JSON.stringify({
      level: 'info',
      message,
      timestamp: new Date().toISOString(),
      ...meta,
    }));
  },
  error: (message, err, meta = {}) => {
    console.error(JSON.stringify({
      level: 'error',
      message,
      error: err?.message,
      stack: err?.stack,
      timestamp: new Date().toISOString(),
      ...meta,
    }));
  },
};

In CloudWatch Logs Insights, you can now query filter level = "error" or filter requestId = "abc123" and get exactly the lines you need. On a single-service setup this feels like overkill. On a fleet of containers all writing to the same log group, it's the difference between diagnosing a problem in two minutes and spending an hour scrolling through noise.

Graceful shutdown

When ECS stops a task — for a deployment, a scale-in event, or a spot interruption — it sends a SIGTERM signal. The default Node.js behaviour is to exit immediately. Any in-flight requests are dropped.

Graceful shutdown catches SIGTERM, stops accepting new connections, waits for in-flight requests to complete, and then exits:

// server.js
const server = app.listen(config.port, () => {
  logger.info(`Server listening on port ${config.port}`);
});

const shutdown = () => {
  logger.info('SIGTERM received, shutting down gracefully');
  server.close(() => {
    logger.info('All connections closed, exiting');
    process.exit(0);
  });

  // Force exit if connections don't close within timeout
  setTimeout(() => {
    logger.error('Forced shutdown after timeout');
    process.exit(1);
  }, 10000);
};

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

ECS gives containers 30 seconds after SIGTERM before sending SIGKILL. A graceful shutdown that completes in under 10 seconds means deployments cause zero dropped requests — even if tasks are replaced mid-request.

The Dockerfile

FROM node:20-alpine

WORKDIR /app

# Install dependencies first — cached unless package.json changes
COPY package*.json ./
RUN npm ci --omit=dev

# Copy application source
COPY src/ ./src/

# Don't run as root
USER node

EXPOSE 3000

CMD ["node", "src/server.js"]

A few things that matter here: npm ci instead of npm install — it installs exactly what's in package-lock.json, no resolution, deterministic builds. --omit=dev keeps dev dependencies out of the production image. USER node runs the process as a non-root user — a container running as root has more access than it should if something goes wrong. Copying package.json before source code means the dependency layer is cached as long as dependencies don't change, making rebuilds fast.

The image should have one job — run the application — and it should do that job the same way every time it starts.

Share

Twitter LinkedIn

Comments (0)

Comments are protected by anti-spam filters and rate limiting.

No comments yet. Start the discussion.