A comprehensive overview of technologies powering modern web applications, from backend to deployment, and the reasoning behind our choices.

Building a modern web application requires making careful technology choices that balance developer experience, performance, and operational simplicity. In this post, I’ll walk through the complete tech stack we chose for our web application and explain the reasoning behind each decision.

Overview

Our web application runs on a full-stack JavaScript/TypeScript foundation with Python for specialized worker processes. The entire stack is containerized with Docker and initially deployed on Amazon Linux 2023, keeping infrastructure lightweight while maintaining production reliability.

Backend: NestJS + Prisma + Postgres + Redis

NestJS (Node 22)

We chose NestJS as our backend framework for its opinionated, modular structure. The decorator-based approach and dependency injection system make it easy to add new features while keeping code organized. TypeScript support is first-class, giving us type safety across the entire application.

Opinionated modules, DI, guards/pipes; easy to grow and keep organized.

@Controller('items')
export class ItemsController {
  constructor(private readonly itemsService: ItemsService) {}
  
  @Get()
  async getItems() {
    return this.itemsService.findAll();
  }

  // Guarded route to protect admin-only actions
  @Post()
  @UseGuards(AdminGuard)
  async createItem(@Body() dto: CreateItemDto) {
    return this.itemsService.create(dto);
  }
}

AdminGuard plugs into NestJS’ standard guard interface, letting us centralize authorization logic and keep controllers thin.

Prisma

Prisma serves as our type-safe ORM with a schema-first approach. The auto-generated TypeScript types eliminate entire classes of runtime errors, and migrations are fast and reliable. The developer experience is exceptional - we get IntelliSense for database queries and compile-time validation.

Type-safe ORM; schema-first; fast migrations; good DX when evolving schemas for reports.

model Item {
  id        String   @id @default(cuid())
  name      String   @unique
  title     String
  value     Float
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

Example query pattern with compile-time safety:

// Find items with value above a threshold and include tags
const items = await prisma.item.findMany({
  where: { value: { gt: 10 } },
  include: { tags: true },
  orderBy: { updatedAt: 'desc' },
});

Migrations stay consistent via prisma migrate deploy baked into our compose workflow, so local and prod schemas stay in lockstep.

PostgreSQL

PostgreSQL provides our reliable relational data store with excellent JSON support. This flexibility works perfectly for our scoring and reporting features, allowing us to store structured data alongside traditional relational data. The ecosystem of extensions and robust tooling made it an obvious choice.

Relational backbone with JSONB flexibility; solid for scoring/reporting and auditability.

Redis

Redis handles caching and queue management, keeping our API responsive under load. We use it for session storage, API response caching, and background job queues. The in-memory performance is essential for real-time data updates.

Caching/queues/light state to keep API latency low and decouple workloads.

Typical cache pattern:

const key = `item:${id}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);

const item = await this.repo.findById(id);
await redis.setex(key, 60, JSON.stringify(item)); // 60s TTL
return item;

For queues, lightweight Redis lists back small background tasks while heavier jobs run in the Python worker.

Health & tooling

/api/health, Prisma CLI, logs via compose/systemd.

Docker Compose

One command brings up our entire development environment: database, Redis, backend, and web frontend. This consistency between local development and production environments eliminates “it works on my machine” issues and simplifies onboarding. Critically, Docker gives us the freedom to switch cloud providers easily - we can move from AWS to DigitalOcean, Vultr, or any other provider with minimal changes to our setup.

systemd units

On production, we use systemd units to keep the stack running and manage scheduled worker tasks on a linux machine. This provides process supervision, automatic restarts, and reliable scheduling without additional complexity.

Frontend: Next.js (App Router) + Tailwind

Next.js

Next.js with the App Router gives us the best of both worlds: server components for fast initial loads and client components where needed. API routes handle simple backend-facing tasks, and the framework’s deployment portability makes hosting straightforward.

Server Components for fast initial paint and smaller bundles; API routes available if needed.

Data fetching

Prefer server components; light SWR for client interactivity. Avoids over-fetching and keeps hydration lean.

// Server component for fast initial load
export default async function ItemPage({ params }: { params: { id: string } }) {
  const item = await getItem(params.id);
  
  return (
    <div>
      <h1>{item.title}</h1>
      <p>Value: {item.value}</p>
    </div>
  );
}

Client-side interactivity with SWR (kept minimal):

const { data, isLoading } = useSWR(`/api/items/${id}`, fetcher, { refreshInterval: 30000 });
if (isLoading) return <p>Loading…</p>;
return <span>{data.value}</span>;

Tailwind CSS

Tailwind enables rapid styling with consistent design tokens. The utility-first approach means we can tweak designs without writing heavy CSS files, and the constraint-based system keeps our UI consistent across the application.

Tailwind for speed and consistency; utility-first keeps the UI coherent without a bespoke design system overhead.

Build/runtime

Next.js standalone output, runs behind Nginx; environment-driven API base URL via compose build args.

SWR (light usage)

We use SWR sparingly for client-side data fetching where server components aren’t sufficient. Most data fetching happens on the server, but SWR handles real-time updates and client-side state when needed.

Worker (Python)

Service/Worker

A Python service runs data processing and scoring tasks via our backend API. This isolation is intentional - the worker communicates through APIs rather than direct database writes, ensuring data integrity and making the system easier to debug and maintain.

Screens data and writes via the backend API (no direct DB writes for safety).

Isolation

Separate container/profile; one-shot runs or long-running mode; scheduled via systemd timer on Linux.

Sample task shape (pseudo-Python):

def run_screen():
    symbols = fetch_symbols()
    scored = score(symbols)
    for item in scored:
        requests.post(f\"{API_BASE}/items\", json=item, headers=auth())

Jobs can run as docker compose run worker for one-shots or long-running in worker profile with a timer.

Reverse Proxy & TLS

Nginx

Nginx fronts both our web application and API, handling gzip compression and routing. It serves as our reverse proxy, directing traffic to the appropriate services and providing a single entry point for our application.

Let’s Encrypt (Certbot)

Automated TLS certificates secure our domains with simple renewals. Certbot handles the certificate lifecycle, so we don’t have to worry about manual certificate management.

Deployment Target

Amazon Linux 2023 on EC2

We chose Amazon Linux 2023 as our deployment target for its minimal, stable base. It’s Docker Compose friendly and includes systemd out of the box. The operating system strikes the right balance between stability and modern features.

Ops & Deployment: Docker Compose + systemd + Nginx + Certbot on Linux

Compose

Single stack (db, redis, backend, web-app, worker profile). Parity between local and prod; platform pinning (linux/amd64) for production.

docker-compose.yml sketch:

services:
  backend:
    build: { context: ./backend, args: { API_BASE: ${API_BASE} } }
    ports: ["8080:8080"]
    depends_on: [db, redis]
  web:
    build: { context: ./web, args: { API_BASE: ${API_BASE} } }
    ports: ["3000:3000"]
  worker:
    build: ./worker
    profiles: ["worker"]
    depends_on: [backend]
  db:
    image: postgres:16
  redis:
    image: redis:7

systemd

Keeps the stack running (app-stack.service) and schedules daily worker (app-daily.timer/service).

Example unit (simplified):

[Unit]
Description=App stack
After=docker.service

[Service]
Type=oneshot
WorkingDirectory=/opt/app
ExecStart=/usr/bin/docker compose up -d
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target

Timers trigger docker compose run worker on a schedule for daily jobs.

Nginx

Reverse proxy to backend (8080) and web (3000); HTTPS termination.

Typical location blocks:

location /api/ {
  proxy_pass http://backend:8080/;
  proxy_set_header Host $host;
}

location / {
  proxy_pass http://web:3000/;
}

Let’s Encrypt (Certbot)

Automated TLS for domains; renewals handled by Certbot.

Renewal check:

sudo certbot renew --dry-run

Portability

Avoids heavy managed services; can rehome to other VPS providers with the same compose + Nginx pattern.

Why This Mix?

Developer Speed

The combination of NestJS + Prisma + Tailwind + Next.js provides excellent developer experience with strong type safety throughout the stack. We can move quickly without sacrificing code quality.

Type safety (Prisma/TypeScript), fast styling (Tailwind), and predictable module structure (NestJS).

Performance

Server components reduce client JS; Redis + Postgres keep responses quick; worker offloads heavy processing.

Operational Simplicity

Docker Compose, systemd, and Nginx keep our infrastructure lightweight. We avoid heavy managed services, which reduces complexity and vendor lock-in while maintaining production reliability.

Compose + systemd + Nginx is lightweight to run on Linux; TLS via Certbot is low-friction.

Portability

The same Docker Compose configuration runs everywhere: Mac development machines, Amazon Linux 2023 production servers, or other VPS providers. This consistency makes development and deployment predictable.

Minimal distro dependencies; compose files drive the same build on Mac (ARM) and Linux (amd64) with DOCKER_DEFAULT_PLATFORM.

Security & Reliability

PostgreSQL provides robust data integrity, TLS is enabled by default, and Redis isolates caching and queuing from our main application. The worker service uses the API rather than direct database access, adding an extra layer of safety.

Worker goes through the backend API; DB stays behind the network boundary; TLS by default.

Conclusion

This tech stack represents a careful balance of modern development practices and operational simplicity. Each technology serves a specific purpose while contributing to a cohesive whole. The result is a system that’s fast to develop, reliable in production, and cost-effective to operate.

For teams building similar applications, this stack provides a solid foundation that can scale from prototype to production without major architectural changes.

FAQ

Why not use a managed database service?
We chose to run PostgreSQL ourselves to keep costs low and maintain full control over our data. Docker Compose makes database management simple, and we avoid vendor lock-in while still getting enterprise-grade reliability.
How do you handle database migrations?
Prisma handles migrations automatically. When we update our schema, Prisma generates the migration files and we apply them with a single command. This works consistently across development and production environments.
Why use Python for the worker instead of Node.js?
Python has excellent libraries for data processing and analysis. The worker performs specialized tasks like data processing and scoring, where Python's scientific computing ecosystem provides better tooling than JavaScript alternatives.
How do you monitor the application in production?
We use systemd for process supervision and basic logging. For a production system of this scale, simple log monitoring and health checks are sufficient. The lightweight approach aligns with our goal of operational simplicity.
Can this stack handle high traffic?
Yes. The combination of Redis caching, Nginx load balancing, and PostgreSQL's performance can handle significant traffic. The Docker Compose setup can be easily scaled by adding more instances behind the load balancer.
What's the deployment process?
Deployment is a single command: `docker-compose up -d` on the production server. We pull the latest images, run database migrations if needed, and restart services. The entire process takes less than 5 minutes.

Welcome to The infinite monkey theorem

Somewhere a monkey just typed Shakespeare in TypeScript. Be the first to read the masterpieces (and the hilarious misfires) landing on the blog.

Subscribe to The infinite monkey theorem

We fling fresh posts—no banana peels attached—straight to your inbox.