Node.js Express Example¶
Complete example: Deploy an Express.js API to Airbase
This example shows how to build and deploy a Node.js Express API server to Airbase, including proper Dockerfile configuration, health checks, and CSP compliance.
Overview¶
What we're building: - REST API server with Express.js - JSON response handling - Health check endpoint - Environment variable configuration - Multi-stage Docker build
Tech stack: - Node.js 22 - Express.js 4.x - ESM (ES Modules)
Deployment time: ~5 minutes
Project Structure¶
express-api/
├── src/
│ ├── index.js # Main application entry point
│ ├── routes/
│ │ └── api.js # API routes
│ └── middleware/
│ └── logger.js # Request logging
├── package.json # Node dependencies
├── package-lock.json # Lockfile
├── Dockerfile # Multi-stage build
├── .dockerignore # Build exclusions
├── airbase.json # Airbase configuration
└── .env.example # Environment variable template
Step 1: Create Express Application¶
package.json:
{
"name": "express-api",
"version": "1.0.0",
"type": "module",
"description": "Express.js API for Airbase",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"express": "^4.18.3"
},
"engines": {
"node": ">=22.0.0"
}
}
Key configuration: - ✅ "type": "module" - Enables ES modules - ✅ "engines" - Specifies Node.js version - ✅ Minimal dependencies (just Express)
Step 2: Create Application Code¶
src/index.js:
import express from 'express';
// Read port from environment (Airbase requirement)
const PORT = process.env.PORT || 3000;
const NODE_ENV = process.env.NODE_ENV || 'development';
const app = express();
// Middleware
app.use(express.json());
// Request logging middleware
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
console.log(`${req.method} ${req.path} ${res.statusCode} ${duration}ms`);
});
next();
});
// Health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
environment: NODE_ENV
});
});
// Root endpoint
app.get('/', (req, res) => {
res.json({
message: 'Welcome to Express API on Airbase',
version: '1.0.0',
endpoints: {
health: '/health',
api: '/api',
users: '/api/users'
}
});
});
// API routes
app.get('/api', (req, res) => {
res.json({
message: 'API endpoint',
documentation: 'https://docs.airbase.sg'
});
});
// Sample resource endpoint
app.get('/api/users', (req, res) => {
// Sample data
const users = [
{ id: 1, name: 'Alice', email: 'alice@example.gov.sg' },
{ id: 2, name: 'Bob', email: 'bob@example.gov.sg' },
{ id: 3, name: 'Charlie', email: 'charlie@example.gov.sg' }
];
res.json({
data: users,
count: users.length
});
});
// Get user by ID
app.get('/api/users/:id', (req, res) => {
const userId = parseInt(req.params.id);
// Sample data
const users = [
{ id: 1, name: 'Alice', email: 'alice@example.gov.sg' },
{ id: 2, name: 'Bob', email: 'bob@example.gov.sg' },
{ id: 3, name: 'Charlie', email: 'charlie@example.gov.sg' }
];
const user = users.find(u => u.id === userId);
if (user) {
res.json({ data: user });
} else {
res.status(404).json({ error: 'User not found' });
}
});
// Create user endpoint (POST)
app.post('/api/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({
error: 'Name and email are required'
});
}
// In a real app, this would save to a database
const newUser = {
id: Date.now(),
name,
email
};
res.status(201).json({
message: 'User created',
data: newUser
});
});
// 404 handler
app.use((req, res) => {
res.status(404).json({
error: 'Not found',
path: req.path
});
});
// Error handler
app.use((err, req, res, next) => {
console.error('Error:', err);
res.status(500).json({
error: 'Internal server error',
message: NODE_ENV === 'development' ? err.message : undefined
});
});
// Start server
app.listen(PORT, '0.0.0.0', () => {
console.log(`Server running on port ${PORT}`);
console.log(`Environment: ${NODE_ENV}`);
console.log(`Health check: http://localhost:${PORT}/health`);
});
Key features: - ✅ Reads PORT from environment variable - ✅ Binds to 0.0.0.0 (required for containers) - ✅ Health check endpoint - ✅ Request logging - ✅ Error handling - ✅ RESTful API patterns - ✅ JSON responses
Step 3: Create Dockerfile¶
Dockerfile:
# Stage 1: Build stage (install dependencies)
FROM gdssingapore/airbase:node-22-builder AS builder
# Set working directory
WORKDIR /app
# Copy package files
COPY --chown=app:app package.json package-lock.json ./
# Install dependencies
RUN npm ci --only=production
# Stage 2: Production stage
FROM gdssingapore/airbase:node-22
# Set environment variables
ENV NODE_ENV=production
# Set working directory
WORKDIR /app
# Copy dependencies from builder
COPY --chown=app:app --from=builder /app/node_modules ./node_modules
# Copy application code
COPY --chown=app:app package.json ./
COPY --chown=app:app src ./src
# Switch to non-root user
USER app
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:' + (process.env.PORT || 3000) + '/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));"
# Start application
CMD ["node", "src/index.js"]
Key features: - ✅ Multi-stage build (smaller final image) - ✅ Uses node-22-builder for installing dependencies - ✅ Uses node-22 for runtime (smaller, no build tools) - ✅ Production-only dependencies (npm ci --only=production) - ✅ Non-root user (app) - ✅ Proper file ownership (--chown=app:app) - ✅ Health check configured - ✅ No unnecessary files in final image
Why multi-stage build? - Smaller image size (~200MB vs ~1GB) - Faster deployments - No build tools in production image - Better security
Step 4: Create .dockerignore¶
.dockerignore:
Why: Excludes unnecessary files from Docker build context.
Step 5: Create Airbase Configuration¶
airbase.json:
{
"framework": "container",
"handle": "your-team/express-api",
"port": 3000,
"instanceType": "nano"
}
Configuration notes: - port: 3000 (matches Express server port) - instanceType: nano is sufficient for basic APIs (0.25 vCPU, 512MB RAM) - handle: Replace with your actual project handle
Step 6: Create Environment Variables (Optional)¶
.env.example:
For staging:
Create .env.staging:
For production:
Create .env:
Deploy to Airbase¶
Build and Deploy to Staging¶
# Navigate to project directory
cd express-api
# Install dependencies locally (for testing)
npm install
# Test locally
npm start
# Visit http://localhost:3000
# Build container
airbase container build
# Deploy to staging
airbase container deploy --yes staging
Expected output:
Building container...
✓ Build complete
✓ Image pushed to registry
Deploying to staging environment...
✓ Deployment successful
✓ Application is live at: https://staging--express-api.app.tc1.airbase.sg
Deployment time: 2-3 minutes
Test in Staging¶
# Test health check
curl https://staging--express-api.app.tc1.airbase.sg/health
# Test root endpoint
curl https://staging--express-api.app.tc1.airbase.sg/
# Test API endpoint
curl https://staging--express-api.app.tc1.airbase.sg/api/users
# Test specific user
curl https://staging--express-api.app.tc1.airbase.sg/api/users/1
# Test POST endpoint
curl -X POST https://staging--express-api.app.tc1.airbase.sg/api/users \
-H "Content-Type: application/json" \
-d '{"name":"David","email":"david@example.gov.sg"}'
What to test: - [ ] Health check responds with 200 OK - [ ] API endpoints return JSON - [ ] Error handling works (404, 400) - [ ] POST requests work - [ ] Response times acceptable
Deploy to Production¶
Production URL: https://express-api.app.tc1.airbase.sg
CSP Compliance¶
Express + CSP¶
Good news: Express APIs are naturally CSP-compliant when serving JSON.
Why APIs are CSP-friendly: 1. No HTML rendering (just JSON responses) 2. No inline scripts 3. No browser execution context 4. CSP headers don't affect API responses
If serving HTML:
If your Express app serves HTML pages, ensure CSP compliance:
// Set CSP header
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
"script-src 'self'"
);
next();
});
// Serve HTML with external scripts only
app.get('/page', (req, res) => {
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>My Page</title>
</head>
<body>
<div id="app"></div>
<!-- ✅ External script (CSP-compliant) -->
<script src="/app.js"></script>
</body>
</html>
`);
});
// Serve the external script
app.use(express.static('public'));
Avoid:
Advanced: Database Integration¶
Adding PostgreSQL¶
Install pg package:
Update package.json:
Create database connection:
src/db.js:
import pg from 'pg';
const { Pool } = pg;
const pool = new Pool({
host: process.env.DB_HOST,
port: process.env.DB_PORT || 5432,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
export default pool;
Update index.js to use database:
import pool from './db.js';
// Get users from database
app.get('/api/users', async (req, res) => {
try {
const result = await pool.query('SELECT * FROM users');
res.json({
data: result.rows,
count: result.rows.length
});
} catch (error) {
console.error('Database error:', error);
res.status(500).json({ error: 'Database error' });
}
});
Update .env:
DB_HOST=your-database-host
DB_PORT=5432
DB_NAME=your_database
DB_USER=your_user
DB_PASSWORD=your_password
Troubleshooting¶
Issue: App not accessible¶
Symptom: Deployment succeeds but URL doesn't respond.
Causes: 1. Not binding to 0.0.0.0 2. Wrong port in airbase.json 3. App crashed on startup
Solution:
Ensure server binds to 0.0.0.0:
Check airbase.json port matches:
Issue: Module not found error¶
Symptom: Container fails to start with "Cannot find module" error.
Cause: Dependencies not installed in Docker image.
Solution:
Check Dockerfile copies node_modules:
Ensure npm ci runs in builder stage:
Issue: Permission denied errors¶
Symptom: App can't write logs or access files.
Cause: Files not owned by app user.
Solution:
Use --chown=app:app when copying files:
Ensure USER app before CMD:
Issue: Slow startup or high memory usage¶
Symptom: App takes long to start or uses too much memory.
Solution 1: Upgrade instance type
Solution 2: Optimize dependencies
Remove unnecessary packages from package.json.
Solution 3: Add caching
Use caching for expensive operations:
import NodeCache from 'node-cache';
const cache = new NodeCache({ stdTTL: 600 });
app.get('/api/users', async (req, res) => {
const cachedUsers = cache.get('users');
if (cachedUsers) {
return res.json({ data: cachedUsers });
}
// Fetch from database
const users = await fetchUsers();
cache.set('users', users);
res.json({ data: users });
});
Issue: CORS errors¶
Symptom: Browser can't access API from different origin.
Solution:
Install cors package:
Add CORS middleware:
import cors from 'cors';
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
methods: ['GET', 'POST', 'PUT', 'DELETE'],
credentials: true
}));
Update .env:
Production Best Practices¶
1. Logging¶
Use structured logging:
import pino from 'pino';
import pinoHttp from 'pino-http';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
});
app.use(pinoHttp({ logger }));
2. Error Handling¶
Catch all unhandled errors:
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
// Application specific logging, throwing an error, or other logic here
});
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
process.exit(1);
});
3. Graceful Shutdown¶
Handle shutdown signals:
const server = app.listen(PORT, '0.0.0.0');
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully');
server.close(() => {
console.log('Server closed');
process.exit(0);
});
});
4. Rate Limiting¶
Protect against abuse:
import rateLimit from 'express-rate-limit';
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: 'Too many requests from this IP'
});
app.use('/api/', limiter);
5. Input Validation¶
Validate request data:
import { body, validationResult } from 'express-validator';
app.post('/api/users',
body('email').isEmail(),
body('name').isLength({ min: 2 }),
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Process valid request
}
);
Production Checklist¶
Before deploying to production:
- Test all API endpoints in staging
- Verify health check responds
- Check error handling (404, 500 responses)
- Test with invalid input
- Verify environment variables are set
- Check logs for errors
- Test performance under load (use
wrkorab) - Ensure proper authentication (if required)
- Set up rate limiting
- Configure CORS properly
- Test database connections (if using)
- Verify proper instance type
Performance Testing¶
Using curl¶
# Simple load test
for i in {1..100}; do
curl -s https://staging--express-api.app.tc1.airbase.sg/health > /dev/null &
done
wait
Using Apache Bench (ab)¶
# Install Apache Bench
brew install httpd # macOS
sudo apt install apache2-utils # Ubuntu
# Run 1000 requests with 10 concurrent connections
ab -n 1000 -c 10 https://staging--express-api.app.tc1.airbase.sg/
Using wrk¶
# Install wrk
brew install wrk # macOS
# Run 30 second test with 10 connections
wrk -t10 -c10 -d30s https://staging--express-api.app.tc1.airbase.sg/
Next Steps¶
Enhance your API: - Add authentication (JWT, OAuth) - Integrate with databases (PostgreSQL, MongoDB) - Add API documentation (Swagger/OpenAPI) - Implement caching (Redis) - Add monitoring and metrics - Set up automated testing
Resources: - Express.js Documentation - Node.js Best Practices - PostgreSQL Node.js Driver
Complete Example Repository¶
You can find the complete working example at:
express-api/
├── src/
│ └── index.js # 150 lines
├── package.json # 20 lines
├── Dockerfile # 30 lines
├── .dockerignore # 10 lines
├── airbase.json # 6 lines
└── .env.example # 5 lines
Total: ~220 lines of code for a complete production-ready API.
See Also¶
- Tutorial: Getting Started - First deployment
- How-To: Build and Deploy - Deployment workflow
- How-To: Deploy Node.js Apps - Node.js deployment guide
- Reference: Dockerfile Requirements - Container requirements
- Reference: Base Images - Node.js base images
- Example: Static Site (Figma Make) - Static site example
- Example: Python Streamlit - Python web app example