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 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
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
- 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
- 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 before app.listen:
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
- Port already in use: change PORT in .env or kill the other process.
- CORS errors from a front end on a different port: set CORS_ORIGIN in .env.
- 429 errors: your rate limit is working; reduce calls or raise RATE_LIMIT_MAX.
- Mongo connection refused: ensure the container is running and listening on 27017.
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 x-request-id
- Rate limit returns 429 when exceeding the set max
- 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.