Every tutorial assumes cloud.
Database on AWS RDS. File storage on S3. Authentication through Auth0. Email through SendGrid. Cache on ElastiCache. Queue on SQS. The architecture diagrams look like a constellation of managed services with your application code somewhere in the middle, surrounded by monthly bills.
I built a full-stack JavaScript application that runs on hardware I own, uses software I control, and costs nothing per month beyond electricity and internet service I was already paying for. Not a toy project — a production application with user authentication, file uploads, background jobs, email delivery, real-time features, and monitoring.
Here’s the complete architecture, every component, and the decisions behind each one.
Why Self-Hosted Made Sense for This Project
The honest answer first: self-hosting isn’t always the right choice. Cloud services trade money for operational simplicity. For many teams that’s the right trade.
For this project the calculation went differently. The application had predictable traffic patterns that made cloud pricing unfavorable — consistent baseline load rather than spikes that benefit from elastic scaling. The data was sensitive enough that controlling where it physically resided mattered. The expected lifespan was long enough that five years of cloud costs would significantly exceed the hardware purchase. And I had the operational knowledge to maintain it.
If any of those conditions were different the architecture would be different. This is a decision matrix, not a manifesto.
The hardware is a single server — a refurbished enterprise machine with 64GB RAM, 12 cores, and a pair of SSDs in a RAID 1 configuration. Purchased for approximately $400. Colocated in a data center that provides power, cooling, and a stable internet connection for $50 a month — the one recurring cost. Total first-year cost significantly below what the equivalent cloud infrastructure would have cost.
The Application Layer — Node.js and Express
The application server is Node.js with Express. Nothing exotic here — the self-hosted architecture doesn’t change what the application code looks like, it changes what the application connects to.
The meaningful decision was process management. In cloud environments, process managers are often abstracted away — the platform handles restarts, monitoring, and process lifecycle. Self-hosted means owning that layer explicitly.
PM2 manages the Node.js processes. It handles automatic restarts when processes crash, starts processes on server boot, manages log rotation, and provides a monitoring interface for process health. The configuration is straightforward but the discipline matters — a process manager is the difference between an application that recovers from crashes and one that waits for manual intervention.
// ecosystem.config.js — PM2 configuration
module.exports = {
apps: [{
name: 'api-server',
script: './src/server.js',
instances: 'max', // Use all available CPU cores
exec_mode: 'cluster', // Cluster mode for load distribution
max_memory_restart: '1G', // Restart if memory exceeds 1GB
env_production: {
NODE_ENV: 'production',
PORT: 3000
},
error_file: './logs/err.log',
out_file: './logs/out.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss',
}]
};
Cluster mode distributes incoming connections across worker processes equal to the number of CPU cores. On a 12-core server, twelve Node.js processes handle requests concurrently — eliminating the single-process bottleneck that makes Node.js appear to not scale.
The Database — PostgreSQL
PostgreSQL replaced what would have been RDS or Cloud SQL. Running a database you manage yourself is the part of self-hosting that most developers find most intimidating and that is, in practice, more manageable than the concern suggests.
PostgreSQL’s configuration for a dedicated server differs from the defaults designed for shared environments. The defaults assume limited memory and disk. A server with 64GB RAM dedicated to this application can be tuned accordingly.
-- postgresql.conf tuning for dedicated server with 64GB RAM
shared_buffers = 16GB -- 25% of RAM
effective_cache_size = 48GB -- 75% of RAM
maintenance_work_mem = 2GB -- For VACUUM, CREATE INDEX
checkpoint_completion_target = 0.9
wal_buffers = 64MB
default_statistics_target = 100
random_page_cost = 1.1 -- SSD storage
effective_io_concurrency = 200 -- SSD concurrent I/O
work_mem = 64MB -- Per-sort operation memory
These settings produce performance that competes with managed database services at equivalent specifications because managed services apply conservative defaults that work across their customer base. A dedicated server can be tuned for the actual workload.
Backups run through a combination of pg_dump for logical backups and WAL archiving for point-in-time recovery. The backup destination is an external hard drive and a second remote location — a friend’s server running a simple rsync target. Not elegant but reliable. The recovery procedure is tested quarterly.
The Reverse Proxy — Nginx as the Front Door
Nginx sits in front of everything. It handles TLS termination, serves static files, proxies requests to the Node.js cluster, and manages routing between multiple applications running on the same server.
# /etc/nginx/sites-available/app.conf
server {
listen 443 ssl http2;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512;
ssl_prefer_server_ciphers off;
# Static files served directly — never touches Node.js
location /static/ {
root /var/www/app;
expires 1y;
add_header Cache-Control "public, immutable";
gzip_static on;
}
# API requests proxied to Node.js cluster
location /api/ {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_cache_bypass $http_upgrade;
}
# WebSocket support for real-time features
location /ws {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name app.example.com;
return 301 https://$server_name$request_uri;
}
TLS certificates come from Let’s Encrypt through Certbot. Free, automatically renewed every 90 days, trusted by all browsers. The automation that cloud providers charge for in certificate management is free and handled by two cron jobs.
Static file serving through Nginx rather than Node.js is the single configuration change with the most performance impact. Static files — JavaScript bundles, CSS, images — are served from disk by Nginx at speeds Node.js can’t match because Nginx is purpose-built for this. Node.js processes handle only dynamic requests.
Authentication — Self-Hosted With Passport.js and JWT
Authentication replaced what would have been Auth0 or Clerk. The choice to own authentication is the one that requires the most security discipline — managed authentication services exist partly because authentication is where security mistakes are most expensive.
The implementation uses Passport.js for authentication strategies, bcrypt for password hashing, and JWT for session tokens. Nothing novel — these are standard tools that have been production-hardened for years.
import passport from 'passport';
import { Strategy as LocalStrategy } from 'passport-local';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
passport.use(new LocalStrategy(
{ usernameField: 'email' },
async (email, password, done) => {
const user = await User.findOne({ where: { email } });
if (!user) return done(null, false, { message: 'User not found' });
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) return done(null, false, { message: 'Invalid password' });
return done(null, user);
}
));
function generateTokens(userId) {
const accessToken = jwt.sign(
{ userId },
process.env.JWT_SECRET,
{ expiresIn: '15m', algorithm: 'HS256' }
);
const refreshToken = jwt.sign(
{ userId, type: 'refresh' },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '30d', algorithm: 'HS256' }
);
return { accessToken, refreshToken };
}
Short-lived access tokens with long-lived refresh tokens — access tokens expire in fifteen minutes, refresh tokens in thirty days. The refresh token is stored in an httpOnly cookie. The access token lives in memory on the client. This pattern means a compromised access token has a fifteen-minute window and a compromised refresh token can be revoked from the database.
The security practices that matter most in self-hosted authentication: secure password hashing with bcrypt at appropriate cost factors, rate limiting on authentication endpoints, and a server-side token revocation mechanism for logout. Managed authentication services handle these by default. Self-hosted authentication requires handling them explicitly.
File Storage — MinIO as S3-Compatible Local Storage
File uploads replaced what would have been S3. MinIO is an open-source object storage system with an S3-compatible API — meaning every piece of code that works with the AWS S3 SDK works with MinIO without modification.
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
// This configuration points to local MinIO - identical code works with S3
const s3Client = new S3Client({
endpoint: process.env.MINIO_ENDPOINT, // http://localhost:9000
region: 'us-east-1', // Required by SDK even for non-AWS
credentials: {
accessKeyId: process.env.MINIO_ACCESS_KEY,
secretAccessKey: process.env.MINIO_SECRET_KEY,
},
forcePathStyle: true, // Required for MinIO
});
async function uploadFile(file, key) {
const command = new PutObjectCommand({
Bucket: process.env.STORAGE_BUCKET,
Key: key,
Body: file.buffer,
ContentType: file.mimetype,
});
await s3Client.send(command);
return key;
}
async function generatePresignedUrl(key, expiresIn = 3600) {
const command = new GetObjectCommand({
Bucket: process.env.STORAGE_BUCKET,
Key: key,
});
return getSignedUrl(s3Client, command, { expiresIn });
}
MinIO stores files on the server’s SSD. For large files or high-volume storage needs, it can be configured to use additional drives or a network-attached storage device. The presigned URL pattern — generating temporary URLs for direct file access rather than proxying files through the application — works identically with MinIO and S3.
The migration path to S3 if needed: change three environment variables. The application code doesn’t know or care whether it’s talking to MinIO or S3.
Email Delivery — Postal as a Self-Hosted SMTP Server
Email replaced what would have been SendGrid or Postmark. Self-hosted email is the component with the most operational complexity — email deliverability involves DNS configuration, reputation management, and adherence to sending practices that cloud email services handle on your behalf.
Postal is a self-hosted mail delivery platform. It handles the SMTP server, bounce processing, click tracking, and the sending infrastructure. The DNS configuration — SPF, DKIM, DMARC records — is the same whether you’re using SendGrid or self-hosted infrastructure.
import nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST, // Postal SMTP server
port: 587,
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
});
async function sendEmail({ to, subject, html, text }) {
const info = await transporter.sendMail({
from: `"App Name" <noreply@example.com>`,
to,
subject,
html,
text,
});
return info.messageId;
}
// Template rendering with no external service dependency
import { render } from '@react-email/render';
import WelcomeEmail from './emails/WelcomeEmail';
async function sendWelcomeEmail(user) {
const html = render(WelcomeEmail({ name: user.name }));
await sendEmail({
to: user.email,
subject: 'Welcome to the app',
html,
});
}
The honest caveat about self-hosted email: deliverability is harder than with managed services. IP reputation takes time to establish. Some ISPs are more aggressive about filtering mail from small senders. For transactional email to users who expect it — password resets, notifications — self-hosted works well. For cold outreach or marketing email, the deliverability investment is harder to justify.
Background Jobs — BullMQ With Redis
Background processing replaced what would have been SQS with Lambda or a managed task queue. BullMQ uses Redis as a job queue — Redis provides the persistence, ordering, and reliability guarantees that make a job queue reliable under failure conditions.
import { Queue, Worker } from 'bullmq';
import Redis from 'ioredis';
const connection = new Redis({
host: process.env.REDIS_HOST,
port: 6379,
maxRetriesPerRequest: null,
});
// Queue definition
const emailQueue = new Queue('email', { connection });
const imageProcessingQueue = new Queue('image-processing', { connection });
// Add jobs
async function queueWelcomeEmail(userId) {
await emailQueue.add('welcome', { userId }, {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
removeOnComplete: 100, // Keep last 100 completed jobs
removeOnFail: 50,
});
}
// Worker - runs in a separate process
const emailWorker = new Worker('email', async (job) => {
const user = await User.findById(job.data.userId);
await sendWelcomeEmail(user);
}, {
connection,
concurrency: 5, // Process 5 jobs simultaneously
});
emailWorker.on('failed', (job, err) => {
logger.error({ jobId: job.id, error: err.message }, 'Email job failed');
});
Redis runs on the same server as everything else. For the job queue use case — storing job state and coordinating workers — Redis’s memory requirements are minimal. The same Redis instance also handles application caching.
BullMQ’s retry logic with exponential backoff handles transient failures — a job that fails because an external service is temporarily unavailable retries with increasing delays rather than immediately hitting the same unavailable service again.
Real-Time Features — Socket.io Without a Message Broker
Real-time features — live notifications, collaborative cursors, presence indicators — replaced what would have been Pusher or Ably. Socket.io provides WebSocket connections with fallback for browsers that don’t support them.
In a single-server deployment, Socket.io works without a message broker. The complexity of scaling WebSockets across multiple servers — which requires a Redis adapter to share connection state — doesn’t apply when everything runs on one machine.
import { Server } from 'socket.io';
import jwt from 'jsonwebtoken';
function setupWebSockets(httpServer) {
const io = new Server(httpServer, {
cors: {
origin: process.env.CLIENT_URL,
credentials: true,
},
});
// Authentication middleware for WebSocket connections
io.use((socket, next) => {
const token = socket.handshake.auth.token;
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
socket.userId = payload.userId;
next();
} catch (err) {
next(new Error('Authentication failed'));
}
});
io.on('connection', (socket) => {
// Join user's personal room for targeted notifications
socket.join(`user:${socket.userId}`);
socket.on('disconnect', () => {
// Handle cleanup
});
});
return io;
}
// Send notification to specific user from anywhere in the application
function notifyUser(io, userId, event, data) {
io.to(`user:${userId}`).emit(event, data);
}
The single-server constraint that makes this straightforward is also a genuine limitation. If the application needed to scale beyond one server, WebSocket connections would need the Redis adapter for cross-server communication. That’s a real architectural upgrade if growth requires it — and switching from no adapter to the Redis adapter is a one-line configuration change.
Monitoring — Self-Hosted Grafana and Prometheus
Monitoring replaced what would have been Datadog or New Relic. Prometheus collects metrics. Grafana visualizes them. Both are open source, both run on the same server, and together they provide observability that competes with paid monitoring services.
import { register, Counter, Histogram, Gauge } from 'prom-client';
import express from 'express';
// Application metrics
const httpRequestDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status_code'],
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5],
});
const activeWebSocketConnections = new Gauge({
name: 'websocket_connections_active',
help: 'Number of active WebSocket connections',
});
// Middleware to record request metrics
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
httpRequestDuration
.labels(req.method, req.route?.path || 'unknown', res.statusCode)
.observe((Date.now() - start) / 1000);
});
next();
});
// Metrics endpoint - Prometheus scrapes this
app.get('/metrics', async (req, res) => {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
});
Prometheus scrapes the /metrics endpoint every fifteen seconds. Grafana queries Prometheus and renders dashboards. Alertmanager sends notifications — through email, through a self-hosted Matrix server, through whatever channel is configured — when metrics cross configured thresholds.
The monitoring stack runs in Docker containers on the same server, isolated from the application processes. The operational overhead is minimal once configured. The visibility it provides — request latency percentiles, error rates, database query times, queue depths, memory usage — is what distinguishes running a production application from hoping a production application is fine.
The Full Architecture in One View
Every component, every dependency, running on one server:
Nginx handles all incoming connections — TLS termination, static files, reverse proxy to Node.js, WebSocket upgrade. Node.js in PM2 cluster mode handles application logic — API requests, WebSocket connections, business logic. PostgreSQL stores relational data. MinIO stores files. Redis handles caching and job queues. BullMQ workers process background jobs. Postal delivers email. Prometheus and Grafana provide monitoring. Certbot renews TLS certificates.
No cloud services. No monthly API bills. No vendor lock-in. No data leaving infrastructure I control.
What This Architecture Doesn’t Solve
The honest section.
Geographic distribution. Users far from the server experience higher latency than they would with a CDN and regionally distributed infrastructure. For a globally distributed user base, this matters. For a regionally concentrated one, a fast server in a good data center is close enough.
Instant scaling. Cloud infrastructure scales in minutes. This architecture scales by adding hardware, which takes days. For applications with unpredictable traffic spikes, the cloud’s elastic scaling is a real advantage.
Operational responsibility. Managed services come with SLAs and support. Self-hosted infrastructure is your responsibility when something goes wrong at 3am. The monitoring setup catches most problems before they become emergencies but doesn’t eliminate operational responsibility.
For this project, all three of these were acceptable tradeoffs. For projects where any of them isn’t acceptable, cloud services are the right answer. The architecture in this article is a genuine alternative, not a universal solution.
The Bill
Hardware purchase, amortized over three years: approximately $11 per month. Colocation: $50 per month. Domain name: $1 per month.
Total: $62 per month.
The equivalent cloud infrastructure — RDS, EC2, S3, ElastiCache, SQS, CloudWatch, Auth0, SendGrid — was estimated at $400 to $600 per month at the expected usage levels.
The difference over three years is significant. The operational investment to close that gap is real. Whether that trade is worth making depends on the project, the team, and the constraints.
For this project, it was.
If this gave you a clearer picture of what self-hosted full-stack actually looks like — follow for more. I write about the architecture decisions that change what’s economically possible when building software.
Comments
Loading comments…