Express.js Hosting on CloudPloy
CloudPloy runs Express.js applications inside Docker containers on servers you provision through AWS, Lightsail, or your own infrastructure. Each application gets its own isolated container, a managed Nginx reverse proxy, automatic SSL certificates, and environment variable storage. This guide covers how to structure your Express.js app for CloudPloy deployment and what to configure once it is running.
How CloudPloy Runs Express.js Apps
When you push code to your connected Git repository, CloudPloy builds a Docker image from your Dockerfile (or a generated one if you do not include one), starts the container on your server, configures Nginx to proxy traffic to it, and provisions a Let's Encrypt SSL certificate. There is no build pipeline to configure - the deployment happens automatically on every push to your configured branch.
Application Requirements
Your Express.js application needs to:
- Listen on the port specified by the
PORTenvironment variable (CloudPloy injects this) - Have a
package.jsonwith astartscript, or include aDockerfile - Not bind to a specific IP address - use
0.0.0.0or omit the host parameter
A minimal Express.js entry point that works correctly on CloudPloy:
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
}); The key requirement is process.env.PORT. CloudPloy assigns a port to your container and passes it via this variable. Hard-coding port 3000 will cause Nginx to fail to reach your app.
Dockerfile for Express.js
Including a Dockerfile gives you full control over the build. This multi-stage build is recommended for production:
FROM node:22-alpine AS base
WORKDIR /app
COPY package*.json ./
FROM base AS deps
RUN npm ci --only=production
FROM base AS build
RUN npm ci
COPY . .
FROM node:22-alpine AS runner
WORKDIR /app
RUN addgroup -g 1001 nodejs && adduser -S -u 1001 -G nodejs nodejs
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/src ./src
COPY package*.json ./
USER nodejs
EXPOSE 3000
CMD ["node", "src/index.js"] Multi-stage builds keep the final image small by excluding development dependencies and build tools. The node:22-alpine base image is around 50MB compared to the 900MB full Node.js image. Smaller images deploy faster and use less disk space.
Environment Variables
CloudPloy provides an environment variable manager in the dashboard. Variables are injected into your container at runtime and never stored in your Git repository. Common variables to configure:
| Variable | Example Value | Purpose |
|---|---|---|
NODE_ENV | production | Disables dev middleware, enables optimizations |
DATABASE_URL | mysql://user:pass@host/db | Database connection string |
REDIS_URL | redis://localhost:6379 | Redis connection for sessions/cache |
JWT_SECRET | 64-char random string | JWT signing key |
SESSION_SECRET | 64-char random string | Express session signing key |
CORS_ORIGIN | https://app.yourdomain.com | Allowed CORS origins |
Never commit a .env file with production secrets. Use the CloudPloy dashboard to manage all environment-specific configuration.
Database Connections
MySQL and MariaDB
CloudPloy installs MySQL or MariaDB directly on your server. Your Express.js app connects via the server's internal IP - not localhost, since each app runs in its own Docker container while the database runs on the host. Use connection pooling to avoid opening a new connection per request:
const mysql = require('mysql2/promise');
const pool = mysql.createPool({
uri: process.env.DATABASE_URL,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
module.exports = pool; PostgreSQL
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
module.exports = pool; Start with a pool size of 10 and adjust based on monitoring data. Too many connections will exhaust the database server's connection limit; too few will queue requests unnecessarily.
Process Management with PM2
If you deploy without a Dockerfile, CloudPloy can manage your Node.js process directly with PM2. Add an ecosystem.config.js to enable cluster mode:
module.exports = {
apps: [
{
name: 'express-app',
script: 'src/index.js',
instances: 'max',
exec_mode: 'cluster',
env_production: {
NODE_ENV: 'production',
},
max_memory_restart: '512M',
error_file: '/dev/stderr',
out_file: '/dev/stdout',
merge_logs: true,
},
],
}; Cluster mode spawns one worker per CPU core. On a 2-core server you get 2 workers; on a 4-core server, 4 workers. This is the simplest way to utilize all available CPU cores without setting up a separate load balancer.
Health Checks
CloudPloy monitors your application by making periodic HTTP requests to a health check endpoint. Configure a lightweight endpoint that checks your critical dependencies:
app.get('/health', async (req, res) => {
try {
await pool.query('SELECT 1');
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
});
} catch (error) {
res.status(503).json({
status: 'unhealthy',
error: error.message,
});
}
}); Set the health check path to /health in your CloudPloy application settings. The platform uses this endpoint to determine when a deployment is ready to receive traffic and to trigger alerts when your app becomes unresponsive.
CORS Configuration
Configure CORS with environment variables so staging and production can have different allowed origins without code changes:
const cors = require('cors');
const allowedOrigins = process.env.CORS_ORIGIN
? process.env.CORS_ORIGIN.split(',')
: ['http://localhost:3000'];
app.use(cors({
origin: allowedOrigins,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
})); Rate Limiting
Add rate limiting to protect your API endpoints. CloudPloy's Nginx layer provides some DDoS protection, but application-level rate limiting handles per-user abuse:
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later' },
});
app.use('/api/', limiter);
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
});
app.use('/api/auth/', authLimiter); Logging
Write logs to stdout and stderr only. CloudPloy captures these streams and makes them available in the dashboard log viewer. Avoid writing to log files inside the container - they are lost when the container restarts:
const morgan = require('morgan');
if (process.env.NODE_ENV === 'production') {
app.use(morgan('combined'));
} else {
app.use(morgan('dev'));
}
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
process.exit(1);
}); Graceful Shutdown
CloudPloy sends SIGTERM to your container before stopping it during deployments. Implement graceful shutdown so in-flight requests complete before the process exits:
const server = app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully');
server.close(() => {
pool.end(() => {
process.exit(0);
});
});
setTimeout(() => {
console.error('Forced shutdown after timeout');
process.exit(1);
}, 30000);
}); Without this handler, active requests are dropped when a new deployment starts. With it, the server stops accepting new connections while existing requests finish.
Background Jobs
For background processing, use BullMQ with Redis. Run job workers as a separate CloudPloy application - same repository, different start command:
// worker.js
const { Worker } = require('bullmq');
const connection = {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT) || 6379,
};
const worker = new Worker('emails', async (job) => {
const { to, subject, body } = job.data;
await sendEmail(to, subject, body);
}, { connection });
worker.on('failed', (job, err) => {
console.error(`Job ${job.id} failed:`, err.message);
}); Create a second application in CloudPloy pointing to the same repository with node worker.js as the start command. A crash in the worker will not affect the web server.
Security Headers
Add Helmet.js to set security headers automatically. This protects against common web vulnerabilities with minimal configuration:
const helmet = require('helmet');
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
},
},
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
})); Deployment Checklist
- Set
NODE_ENV=productionin environment variables - Use
process.env.PORT- never hard-code the port - Add a
/healthendpoint and configure it in CloudPloy settings - Use connection pooling for all database connections
- Implement graceful shutdown with a
SIGTERMhandler - Log to stdout/stderr only - no log files inside the container
- Add rate limiting to public API endpoints
- Set security headers with Helmet.js
- Run
npm auditand fix critical vulnerabilities before deploying
Common Issues
| Problem | Cause | Fix |
|---|---|---|
| 502 Bad Gateway | App not listening on PORT | Use process.env.PORT, not a hard-coded value |
| App crashes on start | Missing environment variable | Check dashboard logs; add required variables |
| Database connection refused | Using localhost inside container | Use the server's internal IP address for the database host |
| Out of memory errors | Memory leak or no PM2 limit | Set max_memory_restart in ecosystem config |
| Slow cold starts | Large node_modules in Docker image | Use multi-stage build with npm ci --only=production |
| Static files returning 404 | Files not copied in Dockerfile | Add COPY public/ ./public/ to Dockerfile |