## Intro

A safe local lab lets you try ideas, debug issues, and learn fast without risking production. In this guide you will:

- Stand up a minimal Express API on localhost

- Add security defaults (helmet, CORS, rate limiting)

- Add structured logging with request IDs

- Write a few Jest + Supertest tests

- Exercise the API with curl and basic load

- Optionally connect a local MongoDB

The result is a repeatable, inspectable workflow you can extend — for example, by adding authentication, a message queue, or background jobs — as your needs grow.

## Workflow Overview

Follow this sequence and verify after each step.

### Prepare the toolchain

- Install Node.js 18+ and Git. Optional: Docker for a local MongoDB.

- Create a fresh directory for the lab:

mkdir express-lab && cd express-lab
npm init -y 

### Install dependencies

 npm install express helmet cors express-rate-limit pino pino-http zod dotenv
npm install --save-dev nodemon jest supertest cross-env 

### Project layout

 express-lab/
 .env # environment variables (never commit secrets)
 package.json
 src/
 index.js # app entry
 routes.js # sample routes
 errors.js # error helpers
 db.js # optional MongoDB connection
 note.model.js # optional Mongoose model
 test/
 app.test.js # basic tests 

### Environment variables

 Create a .env file:

PORT=4000
HOST=127.0.0.1
CORS_ORIGIN=http://localhost:3000
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX=60
NODE_ENV=development
LOG_LEVEL=info 

### package.json scripts

 {
 "name": "express-lab",
 "version": "1.0.0",
 "type": "commonjs",
 "scripts": {
 "dev": "nodemon src/index.js",
 "start": "node src/index.js",
 "test": "cross-env NODE_ENV=test jest --runInBand --detectOpenHandles"
 },
 "jest": {
 "testEnvironment": "node",
 "verbose": false
 }
} 

### App code: security, logging, and endpoints

 src/errors.js

class HttpError extends Error {
 constructor(status, message) {
 super(message);
 this.status = status;
 }
}

function notFound(req, res, next) {
 next(new HttpError(404, 'Not found'));
}

function errorHandler(err, req, res, next) { // eslint-disable-line no-unused-vars
 const status = err.status || 500;
 res.status(status).json({
 error: {
 message: err.message || 'Internal server error',
 status
 }
 });
}

module.exports = { HttpError, notFound, errorHandler }; 
 src/routes.js

const express = require('express');
const { z } = require('zod');
const { HttpError } = require('./errors');

const router = express.Router();

router.get('/health', (req, res) => {
 res.json({ status: 'ok', uptime: process.uptime(), ts: Date.now() });
});

router.post('/echo', (req, res, next) => {
 const schema = z.object({ message: z.string().min(1) });
 const parsed = schema.safeParse(req.body);
 if (!parsed.success) return next(new HttpError(400, 'Invalid body'));
 res.json({ echoed: parsed.data.message });
});

router.get('/math/sum', (req, res, next) => {
 const raw = (req.query.numbers || '').toString();
 if (!raw) return next(new HttpError(400, 'Provide numbers query param'));
 const nums = raw.split(',').map(x => Number(x.trim()));
 if (nums.some(n => Number.isNaN(n))) return next(new HttpError(400, 'numbers must be comma-separated numerics'));
 const sum = nums.reduce((a, b) => a + b, 0);
 res.json({ sum, count: nums.length });
});

module.exports = router; 
 src/index.js

require('dotenv').config();
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const pino = require('pino');
const pinoHttp = require('pino-http');
const routes = require('./routes');
const { notFound, errorHandler } = require('./errors');

const app = express();
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });

app.disable('x-powered-by');
app.set('trust proxy', false);

app.use(pinoHttp({
 logger,
 genReqId: (req) => req.headers['x-request-id'] || `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}));
app.use(helmet());
app.use(cors({ origin: process.env.CORS_ORIGIN || 'http://localhost:3000', credentials: true }));
app.use(express.json({ limit: '100kb' }));

const limiter = rateLimit({
 windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS || 60000),
 max: Number(process.env.RATE_LIMIT_MAX || 60),
 standardHeaders: true,
 legacyHeaders: false
});
app.use(limiter);

app.use('/api', routes);
app.use(notFound);
app.use(errorHandler);

const PORT = Number(process.env.PORT || 4000);
const HOST = process.env.HOST || '127.0.0.1';

app.listen(PORT, HOST, () => {
 logger.info({ PORT, HOST }, 'Server started');
}); 

### Run the lab

 npm run dev
# In another terminal
curl -s http://127.0.0.1:4000/api/health | jq .
curl -s -X POST http://127.0.0.1:4000/api/echo -H 'Content-Type: application/json' -d '{"message":"hello"}' | jq .
curl -s 'http://127.0.0.1:4000/api/math/sum?numbers=1,2,3,4' | jq . 

### Basic tests

 test/app.test.js

const request = require('supertest');
const express = require('express');
const routes = require('../src/routes');
const { notFound, errorHandler } = require('../src/errors');

function createTestApp() {
 const app = express();
 app.use(express.json());
 app.use('/api', routes);
 app.use(notFound);
 app.use(errorHandler);
 return app;
}

describe('Express lab', () => {
 const app = createTestApp();

 test('GET /health returns ok', async () => {
 const res = await request(app).get('/api/health');
 expect(res.statusCode).toBe(200);
 expect(res.body.status).toBe('ok');
 });

 test('POST /echo requires message', async () => {
 const res = await request(app).post('/api/echo').send({});
 expect(res.statusCode).toBe(400);
 });

 test('GET /math/sum sums numbers', async () => {
 const res = await request(app).get('/api/math/sum').query({ numbers: '5,7,8' });
 expect(res.statusCode).toBe(200);
 expect(res.body.sum).toBe(20);
 });
}); 
 Run tests:

npm test 

### Quick load and safety checks

- Rate limit : trigger it to confirm 429 behavior.

 for i in $(seq 1 70); do curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:4000/api/health; done | sort | uniq -c 
 You should see 60 responses with 200 and 10 with 429.

- Request ID : observe logs include a request id. Add -H 'x-request-id: demo-123' and confirm the same id flows into logs.

curl -H 'x-request-id: demo-123' -s http://127.0.0.1:4000/api/health > /dev/null
# Check server logs for demo-123 

### Optional: add a local MongoDB

 If you want a realistic data layer, spin up MongoDB locally. One simple option is to run a container on localhost.

# Start MongoDB locally (default dev settings)
docker run -d --name mongo-lab -p 27017:27017 mongo:6 
 Install Mongoose and add a tiny notes feature:

npm install mongoose 
 src/db.js

const mongoose = require('mongoose');

async function connect(uri) {
 mongoose.set('strictQuery', true);
 await mongoose.connect(uri);
}

module.exports = { connect }; 
 src/note.model.js

const mongoose = require('mongoose');

const NoteSchema = new mongoose.Schema({
 title: { type: String, required: true },
 body: { type: String, required: true }
}, { timestamps: true });

module.exports = mongoose.model('Note', NoteSchema); 
 Extend src/routes.js with CRUD endpoints (keep it small):

// add at top
const Note = require('./note.model');

// create note
router.post('/notes', async (req, res, next) => {
 try {
 const schema = z.object({ title: z.string().min(1), body: z.string().min(1) });
 const parsed = schema.safeParse(req.body);
 if (!parsed.success) return next(new HttpError(400, 'Invalid body'));
 const note = await Note.create(parsed.data);
 res.status(201).json({ id: note._id.toString() });
 } catch (e) { next(e); }
});

// list notes
router.get('/notes', async (req, res, next) => {
 try {
 const notes = await Note.find().sort({ createdAt: -1 }).limit(10).lean();
 res.json({ notes });
 } catch (e) { next(e); }
}); 
 Wire the DB in src/index.js (add near top):

const { connect } = require('./db'); 
 And replace the app.listen block with:

async function start() {
 const mongoUri = process.env.MONGO_URI || 'mongodb://127.0.0.1:27017/express_lab';
 if (process.env.NODE_ENV !== 'test') {
 await connect(mongoUri);
 }
 app.listen(PORT, HOST, () => {
 logger.info({ PORT, HOST }, 'Server started');
 });
}

start().catch(err => {
 logger.error({ err }, 'Failed to start');
 process.exit(1);
}); 
 Try the notes API:

curl -s -X POST http://127.0.0.1:4000/api/notes \
 -H 'Content-Type: application/json' \
 -d '{"title":"first","body":"hello"}' | jq .

curl -s http://127.0.0.1:4000/api/notes | jq . 

### Reproducibility tips

- Keep .env.example in repo so teammates know required vars.

- Lock Node.js version with an .nvmrc or a tool of your choice.

- Pin dependency majors to avoid surprise breaks.

- Add a short README with run, test, and curl examples.

### Troubleshooting

 Symptom Likely cause Fix 
 Port already in use Another process on PORT Change PORT in .env or kill the other process 
 CORS errors from a front end on a different port CORS_ORIGIN mismatch Set CORS_ORIGIN in .env to the front-end origin 
 429 responses on normal traffic Rate limit too strict Raise RATE_LIMIT_MAX or increase RATE_LIMIT_WINDOW_MS 
 Mongo connection refused Container not running docker start mongo-lab and verify docker ps 

## Local Pilot Plan

 Keep the first pilot narrow and verifiable:

- Scope : /health , /echo , /math/sum only; security middleware; logging; 3 tests.

- Time-box : 60 minutes from zero to passing tests.

- Success criteria :

- GET /api/health returns status ok in < 50 ms locally

- POST /api/echo echoes a non-empty message

- GET /api/math/sum returns correct sum for 1,2,3

- Logs include a request id, either generated or from x-request-id header

- Rate limit returns 429 when exceeding the set max (60 req/min by default)

- npm test passes in under 2 seconds on a typical laptop

- Stretch goal : add the /notes endpoints with a local MongoDB and confirm basic CRUD.

## Conclusion

You now have a compact, secure, and testable Express API lab that runs entirely on localhost. The setup is clear and repeatable, so you can tinker safely, measure changes, and extend the endpoints as your needs evolve. Next steps:

- Expand tests to cover edge cases and errors

- Introduce request validation for all routes

- Add a local database or message queue as needed

- Package scripts for one-command setup so the whole team can run the same lab

Build incrementally, verify each step, and keep the pilot small before layering on complexity.