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

  1. Replace the JSON file with a real database (Postgres, SQLite, or Mongo) as soon as you move beyond demos.
  2. Use a strong JWT secret (at least 32 random characters).
  3. Add rate limiting on /login and /register (e.g. express-rate-limit).
  4. HTTPS is mandatory in production — let your reverse proxy or platform handle it.
  5. 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.