You now have a working API with validation, login and security. Two jobs remain, and both are about trust. Tests let you change code without fear, because a failing test tells you what you broke. Deployment puts the API on a server where other people can reach it. In this final lesson you will write tests with the test runner that is built into Node.js, then walk through the steps to run the API on a Linux server.
Make the app testable
In lesson 14, app.js exports a function createApp and does not call listen. A separate server.js reads the settings, creates the app and starts it. This split is what makes testing easy: a test can create its own app with its own settings, start it on a random free port and talk to it with fetch, with no need for any extra library.
import { createApp } from './app.js';
const secret = process.env.JWT_SECRET;
if (!secret || secret.length < 16) {
console.error('Set JWT_SECRET (at least 16 characters) before starting.');
process.exit(1);
}
const app = createApp({
secret,
dataFile: process.env.DATA_FILE || './data/tasks.json',
allowedOrigin: process.env.CLIENT_ORIGIN,
});
const port = Number(process.env.PORT) || 3000;
const server = app.listen(port, () => console.log(`API listening on port ${port}`));
process.on('SIGTERM', () => {
console.log('Shutting down');
server.close(() => process.exit(0));
});
The server file also does two production jobs. It refuses to start without a long enough secret. And it listens for SIGTERM, the signal that process managers send to stop a program, and closes the server tidily so running requests can finish.
Tests with node:test
Node.js includes a test runner. A test is a function with a name. assert checks that things are as expected. The file below tests the main paths: a login is required, a user can register and create a task, bad input is rejected, and a task can be updated and deleted.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp } from '../app.js';
process.env.NODE_ENV = 'test';
let server, base, token;
test.before(async () => {
const app = createApp({ secret: 'test-secret-1234567890' });
server = app.listen(0);
base = `http://localhost:${server.address().port}`;
});
test.after(() => server.close());
const call = (path, { method = 'GET', body, auth = true } = {}) =>
fetch(base + path, {
method,
headers: { 'Content-Type': 'application/json', ...(auth && token ? { Authorization: `Bearer ${token}` } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
test('tasks need a login', async () => {
const res = await call('/api/tasks', { auth: false });
assert.equal(res.status, 401);
});
test('register, then create and read a task', async () => {
const reg = await call('/api/auth/register', { method: 'POST', body: { email: 'a@example.com', password: 'longenough1' } });
assert.equal(reg.status, 201);
token = (await reg.json()).token;
const created = await call('/api/tasks', { method: 'POST', body: { title: 'Write tests' } });
assert.equal(created.status, 201);
const task = await created.json();
assert.equal(task.title, 'Write tests');
assert.equal(task.done, false);
const list = await (await call('/api/tasks')).json();
assert.equal(list.length, 1);
});
test('bad input is rejected with 400', async () => {
const res = await call('/api/tasks', { method: 'POST', body: { title: '' } });
assert.equal(res.status, 400);
assert.equal((await res.json()).error, 'title is required');
});
test('a task can be updated and deleted', async () => {
const { id } = await (await call('/api/tasks', { method: 'POST', body: { title: 'Temp' } })).json();
const patched = await call(`/api/tasks/${id}`, { method: 'PATCH', body: { done: true } });
assert.equal((await patched.json()).done, true);
assert.equal((await call(`/api/tasks/${id}`, { method: 'DELETE' })).status, 204);
assert.equal((await call(`/api/tasks/${id}`)).status, 404);
});
Look at the structure. test.before starts the app on port 0, which means the system picks any free port, and test.after stops it. The small call helper sends JSON and adds the token when we have one. Each test(...) block checks one behaviour. The tests run in order and share the token variable, which is simple, but it also means that one failure can cause later ones to fail. For bigger suites, give each test its own setup.
npm test
ok 1 - tasks need a login
ok 2 - register, then create and read a task
ok 3 - bad input is rejected with 400
ok 4 - a task can be updated and deleted
# tests 4
# pass 4
# fail 0
The real output also shows timings and other details, and the timings will be different on your computer. The test script runs node --test, which finds files that end in .test.js and runs them. Make a habit of running the tests before every commit. When you find a bug, write a test that fails because of it, then fix the code until the test passes. The bug can then never return unnoticed.
Preparing for production
- Set
NODE_ENV=production. Express and many libraries then switch to faster, safer behaviour. For example, the default error page no longer shows stack traces. - Install only what you need:
npm install --omit=dev. - Set every secret and setting in the server's environment:
JWT_SECRET,MONGODB_URIorDATA_FILE,CLIENT_ORIGIN,PORT. - Use a long random
JWT_SECRETthat exists only on the server. You can make one withnode -e "console.log(require('crypto').randomBytes(48).toString('base64url'))". - Use a database, not a JSON file, for anything you cannot afford to lose.
Keep it running with pm2
If you start the API with node server.js in a terminal, it stops when you close the terminal or when it crashes. A process manager keeps it running, restarts it after a crash and starts it again after a reboot. pm2 is a popular one for Node.js. These commands run on your server.
npm install -g pm2
pm2 start server.js --name taskapi --node-args="--env-file=.env"
pm2 logs taskapi # watch the logs
pm2 restart taskapi # after deploying new code
pm2 save # remember the list of processes
pm2 startup # prints a command to start pm2 on boot
The program listens on a port on the server, such as 3100. You do not want visitors to type that port, and you want HTTPS.
Put nginx in front
A reverse proxy, usually nginx, accepts connections on the normal ports 80 and 443, handles HTTPS, and passes requests to your Node.js program on its own port. Here is a minimal server block. It is a sketch to adapt, and not a complete production setup.
server {
server_name api.example.com;
location / {
proxy_pass http://127.0.0.1:3100;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
- Get a free certificate for HTTPS from Let's Encrypt, with a tool such as
certbot, and let it set up the renewal. - Because nginx now sits in front, add
app.set('trust proxy', 1)to the app, so the rate limiter sees real client addresses and not the proxy's. - Only open ports 80 and 443 to the world. Keep the Node port closed in the firewall.
example.comis a placeholder. Use your own domain.
Health checks and logs
The /api/health route gives monitoring tools and load balancers something to call. Check it after each deploy. Read logs with pm2 logs, and watch for repeated errors. Do not log passwords, tokens or personal data. For a busy service, send logs to a central service, so they survive a server loss.
A deploy routine
- Run the tests on your machine. Commit and push.
- On the server, pull the new code:
git pull. - Install what is needed:
npm install --omit=dev. - Restart:
pm2 restart taskapi. - Check
/api/healthand read the first lines of the logs. - If something is wrong, go back to the last good commit and restart.
Before you launch: a short checklist
- Tests pass, and secrets are not in git.
- HTTPS works, and the secret is long and random.
- CORS allows only your front end.
- Rate limits are on for login and registration.
- The database has a restricted user, and backups run.
- Errors show a general message to clients and full detail in the logs.
- You know who to call, and what to do, if something breaks.
Where to go next
You now know the core of back-end work with Node.js: modules, async code, Express, validation, storage, login, security, tests and deployment. Next, connect a front end. Our MERN stack course builds a complete app with React on top of an API like this one. Add features to your task API: due dates, tags, paging, password reset with email. And read the official documentation for Node.js, Express and Mongoose when you meet something new, because it is the most reliable source. The career roadmaps show how this skill fits into back-end and full stack roles.
Your task
- Add
testandstartscripts, write the tests above and runnpm test. - Write a new test for a case of your own, for example that a second user cannot read the first user's task, and make it pass.
- Make a plan for deployment of your API: where it would run, where the secrets would be kept, and how you would roll back.
- If you have a server, deploy the API with pm2 and check
/api/health.
How to check it worked
npm testshows all tests passing.- The server refuses to start when
JWT_SECRETis missing, and prints a clear message. - You can describe, in your own words, what pm2 and nginx each do.