A production-ready, secure Node.js/Express REST API for user authentication, role-based access control, and advanced bot protection. Built with modern DevOps practices: containerized workflows, automated CI/CD pipelines, comprehensive test coverage, and serverless database integration.
Perfect for: SaaS platforms, admin dashboards, microservices that need bulletproof auth + security.
- JWT-based auth with secure HTTP-only cookies (15-min expiry, SameSite=Strict)
- Password hashing via bcrypt (10 rounds)
- Role-based access control (RBAC): Admin, User, Guest
- Token verification & expiration handling on protected routes
- Arcjet integration: Multi-layer threat detection
- Bot detection (blocks non-whitelisted bots; allows Google/Bing crawlers)
- DDoS shield (SQL injection, XSS, common attack patterns)
- Sliding-window rate limiting (role-based: 5 req/min for guests, 10 for users, 20 for admins)
- Helmet.js: Secure HTTP headers (CSP, HSTS, X-Frame-Options, etc.)
- CORS: Strict origin validation
- Input validation: Zod schemas on all endpoints (email, password strength, ID validation)
- Winston: Structured JSON logging to disk + console
- Error logs:
logs/error.log - Combined logs:
logs/combined.log
- Error logs:
- Morgan: HTTP request/response logging stream β Winston
- Health check endpoint: Uptime, status, timestamp
- Express 5: Latest features with async/await middleware
- Drizzle ORM: Type-safe, edge-friendly database queries
- Neon PostgreSQL: Serverless, branch-based development, auto-scaling
- Node 22: Native ESM, latest performance improvements
- Multi-stage Dockerfile: Optimized prod & dev images (Alpine Linux)
- Docker Compose: Dev with Neon Local, Prod with Neon Cloud
- Docker Hub: Automated pushes for
mainbranch (multi-platform: amd64, arm64) - Hot-reload dev environment: File watching with
--watchflag
- Jest with ESM support (
NODE_OPTIONS='--experimental-vm-modules') - Supertest: HTTP assertion library for endpoint testing
- Coverage reports: LCOV HTML reports; tracked across runs
- ESLint + Prettier: Auto-formatting and linting on every PR
See CI/CD Workflows below.
| Package | Version | Purpose |
|---|---|---|
express |
^5.1.0 | HTTP framework |
@neondatabase/serverless |
^1.0.2 | Neon DB client |
drizzle-orm |
^0.44.6 | Type-safe ORM |
@arcjet/node |
^1.0.0-beta.13 | Bot/rate-limit protection |
helmet |
^8.1.0 | HTTP header security |
bcrypt |
^6.0.0 | Password hashing |
jsonwebtoken |
^9.0.2 | JWT signing/verification |
zod |
^4.1.12 | Input schema validation |
winston |
^3.18.3 | Structured logging |
morgan |
^1.10.1 | HTTP request logging |
cookie-parser |
^1.4.7 | Cookie middleware |
cors |
^2.8.5 | CORS middleware |
dotenv |
^17.2.3 | Environment variables |
jest,supertestβ Testing framework & HTTP assertionsdrizzle-kitβ DB migrations & schema generationeslint,prettierβ Code quality & formatting
- Node.js 20+ (or 22 recommended)
- npm or yarn
- Docker & Docker Compose (for containerized runs)
- PostgreSQL or Neon account (for database)
git clone https://github.com/Sainava/Acquisitions.git
cd Acquisitions
npm installCreate .env (or .env.development/.env.production):
# Core
PORT=3000
NODE_ENV=development
# Database (Neon Cloud or local Postgres)
DATABASE_URL=postgres://username:password@host:5432/database?sslmode=require
# Security
JWT_SECRET=your-super-secret-key-change-in-production
# Arcjet Bot Protection
ARCJET_KEY=your-arcjet-site-key
# Optional: Neon Local Development
NEON_LOCAL=false
NEON_API_KEY=your-neon-api-key
NEON_PROJECT_ID=your-neon-project-id# Start dev server with auto-reload
npm run dev
# β
Server runs on http://localhost:3000# Run Jest suite + coverage report
npm test
# β
Coverage HTML: open coverage/lcov-report/index.html# Lint check
npm run lint
# Auto-fix ESLint & Prettier
npm run lint:fix
npm run formatBenefits: Ephemeral branches, zero cloud cost, offline-friendly
npm run dev:dockerThis runs scripts/dev.sh, which:
- Spins up Neon Local proxy (port 5432)
- Starts app container with hot-reload
- Runs schema migrations automatically
- Streams logs to foreground
Access:
- API: http://localhost:3000
- Database:
postgres://neon:npg@localhost:5432/appdb
Stop: Ctrl+C or docker compose -f docker-compose.dev.yml down
Benefits: Scalable, secure, managed backups, CI/CD ready
npm run prod:dockerThis runs scripts/prod.sh, which:
- Builds optimized production image (no dev deps)
- Connects to Neon Cloud via
DATABASE_URL - Runs migrations inside container
- Exposes single container on port 3000
Customize: Edit docker-compose.prod.yml or pass env vars:
docker compose -f docker-compose.prod.yml up \
-e DATABASE_URL="postgres://..." \
-e JWT_SECRET="..." \
-e ARCJET_KEY="..."Deploy to: AWS ECS, Google Cloud Run, Azure Container Instances, Render, Heroku, Railway, etc.
GET /healthReturns: { status: "OK", timestamp: "...", uptime: 42.3 }
GET /apiReturns: { message: "Acquisitions API is running" }
POST /api/auth/sign-up
Content-Type: application/json
{
"name": "John Doe",
"email": "john@example.com",
"password": "SecurePass123",
"role": "user"
}Response (201):
{
"message": "User registered successfully",
"user": {
"id": 1,
"name": "John Doe",
"email": "john@example.com",
"role": "user"
}
}Set-Cookie: token=<jwt>; HttpOnly; Secure; SameSite=Strict; Max-Age=900
POST /api/auth/sign-in
Content-Type: application/json
{
"email": "john@example.com",
"password": "SecurePass123"
}Response (200): Same as sign-up + cookie set
POST /api/auth/sign-out
Cookie: token=<jwt>Response (200): { message: "Sign-out successful" } + cookie cleared
All require: Valid JWT cookie + "Authorization: Bearer <token>" header (or cookie)
GET /api/users
Cookie: token=<jwt>Auth: Admin only
Response (200):
{
"message": "Successfully retrieved users",
"users": [...],
"count": 5
}GET /api/users/1
Cookie: token=<jwt>Auth: Any authenticated user
Response (200): User object with id, name, email, role, createdAt, updatedAt
PUT /api/users/1
Cookie: token=<jwt>
Content-Type: application/json
{
"name": "Jane Doe",
"email": "jane@example.com"
}Auth: Users can update own profile; admins can update any
Response (200): Updated user object
DELETE /api/users/2
Cookie: token=<jwt>Auth: Admin only (cannot delete own account)
Response (200): Deleted user object
- Hashed with bcrypt (10 rounds, ~100ms per hash)
- Never stored in plaintext
- Compared securely on sign-in
- Payload:
{ id, email, role } - Expiry: 1 day
- Secret: Loaded from
JWT_SECRETenv var - Storage: Secure HTTP-only cookie (can't be accessed by JavaScript)
| Role | Limit | Window |
|---|---|---|
| Guest | 5 req | 1 min |
| User | 10 req | 1 min |
| Admin | 20 req | 1 min |
- β Whitelisted: Google, Bing, other search engines
- β Blocked: Unknown bots
- π‘οΈ Shield: Protects against SQL injection, XSS, CSRF
- Strict origin validation
- Credentials allowed (cookies)
- Safe methods: GET, HEAD, OPTIONS
npm test- tests/app.test.js: Endpoint tests (health, API status, 404)
npm test
# β
Generates: coverage/lcov-report/index.html
# Open in browser to view line/branch/function coverageNODE_ENV=test(Arcjet bypassed for deterministic tests)DATABASE_URL=postgres://testuser:testpass@localhost/testdb(in-memory or docker-based)
describe('POST /api/auth/sign-up', () => {
it('should register a new user', async () => {
const response = await request(app)
.post('/api/auth/sign-up')
.send({
name: 'Test User',
email: 'test@example.com',
password: 'TestPass123',
})
.expect(201);
expect(response.body.user.email).toBe('test@example.com');
expect(response.headers['set-cookie']).toBeDefined(); // JWT cookie
});
});Single table: users
| Column | Type | Default |
|---|---|---|
| id | serial (PK) | auto-increment |
| name | varchar(255) | β |
| varchar(255) | β | |
| password | varchar(255) | β |
| roll | varchar(50) | 'user' |
| created_at | timestamp | now() |
| updated_at | timestamp | now() |
Generate migration from TypeScript schema:
npm run db:generateRun pending migrations:
npm run db:migrateOpen Drizzle Studio (GUI for DB):
npm run db:studiodrizzle.config.js points to:
- Schema:
src/models/user.model.js - Output:
drizzle/(migrations + metadata) - Database: Neon Cloud or Local via
DATABASE_URL
All pipelines automate on push to main or staging + pull requests.
1οΈβ£ Lint & Format (.github/workflows/lint-and-format.yml)
Triggers: Every PR + push to main/staging
Steps:
- Checkout code
- Setup Node.js 20
- Install dependencies
- Run ESLint (
npm run lint) - Run Prettier check (
npm run format:check) - Fail if: Lint errors or formatting issues
Annotation: "Run npm run lint:fix && npm run format to fix"
2οΈβ£ Tests (.github/workflows/tests.yml)
Triggers: Every PR + push to main/staging
Steps:
- Checkout code
- Setup Node.js 20
- Install dependencies
- Set
NODE_ENV=test+NODE_OPTIONS='--experimental-vm-modules' - Run Jest suite (
npm test) - Upload coverage reports (30-day retention)
- Generate test summary in PR
- Fail if: Any test fails
Artifacts: Coverage HTML available for 30 days
3οΈβ£ Docker Build & Push (.github/workflows/docker-build-and-push.yml)
Triggers: Push to main only (or manual via workflow_dispatch)
Prerequisites:
- Docker Hub credentials in GitHub Secrets:
DOCKER_USERNAMEDOCKER_PASSWORD
Steps:
- Checkout code
- Setup Docker Buildx (multi-platform builder)
- Authenticate to Docker Hub
- Extract metadata (branch, SHA, latest tag, timestamp)
- Build & push image
- Platforms:
linux/amd64(Intel/AMD) +linux/arm64(Apple Silicon) - Cache: GitHub Actions cache for faster rebuilds
- Platforms:
- Publish summary to GitHub
Image Tags:
tag:main(branch name)tag:sha-abc123def(commit SHA, truncated)tag:latest(stable alias)tag:prod-20260318-140530(timestamp for audit)
Push to: docker.io/<DOCKER_USERNAME>/acquisitions-app
Uses: docker/build-push-action@v5
.
βββ src/
β βββ index.js # Entry point (loads .env)
β βββ server.js # HTTP server bind
β βββ app.js # Express app + middleware setup
β βββ config/
β β βββ logger.js # Winston logger config
β β βββ database.js # Drizzle + Neon connection
β β βββ arcjet.js # Arcjet rules (bot, shield, rate-limit)
β β βββ neon.local.js # Neon Local dev mode config
β βββ routes/
β β βββ auth.routes.js # POST /api/auth/sign-{up,in,out}
β β βββ users.routes.js # GET/PUT/DELETE /api/users/:id
β βββ controllers/
β β βββ auth.controller.js # Sign up/in/out logic
β β βββ users.controller.js # User CRUD handlers
β βββ services/
β β βββ auth.service.js # Auth business logic (hash, create, verify)
β β βββ users.service.js # User DB queries
β βββ middleware/
β β βββ auth.middleware.js # JWT verify + role checks
β β βββ security.middleware.js # Arcjet bot/rate/shield
β βββ models/
β β βββ user.model.js # Drizzle schema
β βββ validations/
β β βββ auth.validation.js # Zod schemas for sign-up/in
β β βββ users.validation.js # Zod schemas for user operations
β βββ utils/
β βββ jwt.js # JWT sign/verify helpers
β βββ cookies.js # Cookie set/clear helpers
β βββ format.js # Validation error formatter
βββ tests/
β βββ app.test.js # Jest endpoint tests
βββ drizzle/
β βββ 0000_aberrant_outlaw_kid.sql # Migration file
β βββ meta/
β βββ _journal.json # Migration history
β βββ 0000_snapshot.json # Schema snapshot
βββ scripts/
β βββ dev.sh # Docker dev bootstrap
β βββ prod.sh # Docker prod bootstrap
βββ .github/workflows/
β βββ lint-and-format.yml # ESLint + Prettier CI
β βββ tests.yml # Jest CI
β βββ docker-build-and-push.yml # Multi-platform Docker push
βββ Dockerfile # Multi-stage build (dev + prod)
βββ docker-compose.dev.yml # Dev: Neon Local + app
βββ docker-compose.prod.yml # Prod: app only
βββ eslint.config.js # ESLint rules
βββ jest.config.mjs # Jest config (v30, ESM)
βββ drizzle.config.js # Drizzle migration config
βββ package.json # Scripts + dependencies
βββ README.md # This file
βββ Docker_documentation.md # Detailed Docker runbook
npm run dev
# β
Auto-restarts on file changes# Check code style
npm run lint
npm run format:check
# Auto-fix issues
npm run lint:fix
npm run format# Generate migration from schema changes
npm run db:generate
# Apply migrations
npm run db:migrate
# Open interactive DB studio
npm run db:studioCreate .env in root:
PORT=3000
NODE_ENV=development
DATABASE_URL=postgres://...
JWT_SECRET=your-secret
ARCJET_KEY=your-keyNever commit .env files; add to .gitignore:
.env
.env.local
.env.*.local
-
Build & Push (automated on
mainvia CI/CD):# Manual push (if needed): docker buildx build --push \ --platform linux/amd64,linux/arm64 \ -t docker.io/yourname/acquisitions-app:latest .
-
Pull & Deploy to AWS ECS, Render, Railway, etc.:
docker pull docker.io/yourname/acquisitions-app:latest docker run \ -e DATABASE_URL="postgres://..." \ -e JWT_SECRET="..." \ -e ARCJET_KEY="..." \ -p 3000:3000 \ docker.io/yourname/acquisitions-app:latest
# On remote server
git clone <repo> /opt/acquisitions
cd /opt/acquisitions
npm ci --omit=dev
npm run db:migrate
# Create systemd service
sudo nano /etc/systemd/system/acquisitions.service[Unit]
Description=Acquisitions API
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/acquisitions
ExecStart=/usr/bin/node src/index.js
Restart=on-failure
User=appuser
Environment="NODE_ENV=production"
[Install]
WantedBy=multi-user.targetsudo systemctl enable acquisitions
sudo systemctl start acquisitions
sudo systemctl status acquisitionsConnect GitHub repo; platform auto-deploys on push to main.
Set env vars in platform dashboard:
DATABASE_URLβ Neon Cloud connection stringJWT_SECRETβ Secure random stringARCJET_KEYβ Your Arcjet keyNODE_ENV=production
- Ensure
package.jsonhas correct"imports"mapping - Clear cache:
rm -rf node_modules && npm ci
- In dev, Arcjet may flag client tools as bots
- Set
ARCJET_MODE=DRY_RUNtemporarily (logs, doesn't block) - Or: Whitelist your IP in Arcjet dashboard
- Ensure
NODE_ENV=testandDATABASE_URLset in workflow - Coverage artifacts may reveal missing mocks or edge cases
- Ensure
.env.developmentor.env.productionexists OR - Pass via
docker run -e DATABASE_URL="..."
- Ensure
NEON_LOCAL=truein.env.development - Check running containers:
docker ps - View container logs:
docker logs neon-local
ISC License β See repository for details.
Need help? Open a GitHub issue or check the Docker_documentation.md for advanced setup.
Happy building! π