A complete walkthrough: JWT authentication, protected routes, Docker packaging, and a clean production-ready setup you can actually run.
Most authentication tutorials stop at “it works on localhost.” This one goes further — you’ll end up with a containerized API that handles registration, login, protected endpoints, and is ready to deploy anywhere Docker runs.
What you’ll build
- User registration and login
- JWT-based authentication
- One protected route (
/me) - Docker image that runs the whole thing
- Clear environment variable handling
Stack: Node.js + Express + JSON file storage (for simplicity) + Docker.
You can later swap the JSON file for Postgres or SQLite without changing the Docker approach.
Project structure
auth-api/
├── src/
│ ├── index.js
│ ├── auth.js
│ └── users.json # created at runtime
├── Dockerfile
├── .dockerignore
├── package.json
└── .env.example
Step 1 – Initialize the project
mkdir auth-api && cd auth-api
npm init -y
npm install express jsonwebtoken bcryptjs dotenv cors
npm install -D nodemon
package.json scripts:
{
"scripts": {
"dev": "nodemon src/index.js",
"start": "node src/index.js"
}
}
Step 2 – Core authentication logic
Create src/auth.js:
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const fs = require('fs');
const path = require('path');
const USERS_FILE = path.join(__dirname, 'users.json');
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me';
// Ensure users file exists
if (!fs.existsSync(USERS_FILE)) {
fs.writeFileSync(USERS_FILE, '[]');
}
function readUsers() {
return JSON.parse(fs.readFileSync(USERS_FILE, 'utf8'));
}
function writeUsers(users) {
fs.writeFileSync(USERS_FILE, JSON.stringify(users, null, 2));
}
async function register(email, password) {
const users = readUsers();
if (users.find(u => u.email === email)) {
throw new Error('Email already registered');
}
const hashed = await bcrypt.hash(password, 10);
const user = { id: Date.now().toString(), email, password: hashed };
users.push(user);
writeUsers(users);
return { id: user.id, email: user.email };
}
async function login(email, password) {
const users = readUsers();
const user = users.find(u => u.email === email);
if (!user) throw new Error('Invalid credentials');
const match = await bcrypt.compare(password, user.password);
if (!match) throw new Error('Invalid credentials');
const token = jwt.sign(
{ sub: user.id, email: user.email },
JWT_SECRET,
{ expiresIn: '7d' }
);
return { token, user: { id: user.id, email: user.email } };
}
function authMiddleware(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
try {
const payload = jwt.verify(header.slice(7), JWT_SECRET);
req.user = payload;
next();
} catch {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
module.exports = { register, login, authMiddleware };
Step 3 – Express server
Create src/index.js:
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const { register, login, authMiddleware } = require('./auth');
const app = express();
const PORT = process.env.PORT || 3000;
app.use(cors());
app.use(express.json());
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.post('/register', async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
const user = await register(email, password);
res.status(201).json({ user });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
app.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
const result = await login(email, password);
res.json(result);
} catch (err) {
res.status(401).json({ error: err.message });
}
});
app.get('/me', authMiddleware, (req, res) => {
res.json({ user: req.user });
});
app.listen(PORT, () => {
console.log(`API running on port ${PORT}`);
});
Step 4 – Environment variables
.env.example:
PORT=3000
JWT_SECRET=change-this-to-a-long-random-string
Never commit real secrets. In production you’ll pass them via Docker or your hosting platform.
Step 5 – Dockerize it
Dockerfile:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "src/index.js"]
.dockerignore:
node_modules
.env
*.md
.git
Build and run:
docker build -t auth-api .
docker run --rm -p 3000:3000 \
-e JWT_SECRET=super-secret-key-here \
auth-api
Test it:
# Register
curl -X POST http://localhost:3000/register \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"secret123"}'
# Login
curl -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"secret123"}'
# Use the token
curl http://localhost:3000/me \
-H "Authorization: Bearer <your-token>"
Production notes
- Replace the JSON file with a real database (Postgres, SQLite, or Mongo) as soon as you move beyond demos.
- Use a strong JWT secret (at least 32 random characters).
- Add rate limiting on
/loginand/register(e.g.express-rate-limit). - HTTPS is mandatory in production — let your reverse proxy or platform handle it.
- For multi-instance deployments, switch to a shared session store or stick with pure JWTs (stateless).
What you learned
- How JWT authentication actually works end-to-end
- How to keep secrets out of the image
- A minimal but production-ready Dockerfile pattern
- The difference between “works on my machine” and “runs in a container”
This same pattern scales: swap the storage layer, add refresh tokens, or put Nginx in front — the Docker and auth foundation stays the same.