In modern fullstack engineering with Next.js 15, the application layer is no longer a rudimentary HTML document generator. The App Router operates as a high-throughput backend service, actively coordinating with complex infrastructure systems:
Attempting to coordinate these heterogeneous services natively across disparate developer workstations triggers environmental drift ("works on my machine") and inflates engineering onboarding overhead. To eliminate divergence, we employ Docker Compose (Compose Spec v2).
[Local Fullstack Topology]
Developer Workstation / Web Browser
(HTTP: localhost:3000, MinIO Console: localhost:9001)
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Docker Network: backend-net (Isolated Bridge Network) │
│ │
│ ┌────────────────────┐ ┌─────────────────────────┐ │
│ │ web (Next.js) │ ─────────► │ postgres (PostgreSQL) │ │
│ │ │ TCP: 5432 │ Volume: postgres_data │ │
│ │ • Server Actions │ └─────────────────────────┘ │
│ │ • Route Handlers │ │
│ │ • RSC SSR │ ─────────► ┌─────────────────────────┐ │
│ │ │ TCP: 6379 │ redis (Redis 7) │ │
│ │ │ │ Volume: redis_data │ │
│ │ │ └─────────────────────────┘ │
│ │ │ │
│ │ │ ─────────► ┌─────────────────────────┐ │
│ │ │ S3: 9000 │ minio (MinIO S3) │ │
│ │ │ │ Volume: minio_data │ │
│ │ │ └────────────▲────────────┘ │
│ └────────────────────┘ │ │
│ ┌────┴────────────┐ │
│ │ minio-init │ │
│ │ Creates buckets │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────┘The primary engineering challenges in orchestrating this cluster involve Service Discovery and Startup Ordering with Healthchecks.
Within an isolated bridge network, Docker Compose manages an embedded DNS server (127.0.0.11), automatically resolving service names (postgres, redis, minio) to their dynamic container IP addresses. This decouples service communication from host IP bindings.
The mental model of Docker Compose revolves around a Health-Conditioned Dependency Graph.
A common novice misconception is assuming that the basic depends_on: ['postgres'] directive guarantees database readiness. In reality, Docker flags a container as started the microsecond the Linux kernel allocates its process. At that exact moment, the PostgreSQL daemon is only beginning to parse configurations, verify write-ahead logs (WAL), and bind to socket ports. If Next.js boots simultaneously and executes Prisma or Drizzle migrations, the application crashes with ECONNREFUSED.
To eliminate this race condition, Compose pairs two directives:
healthcheck: A periodic probe executed inside the dependency container (e.g. pg_isready for Postgres or redis-cli ping for Redis). The service is flagged as healthy only when the daemon accepts live TCP traffic.depends_on with condition: service_healthy: Suspends dependent service startup until upstream dependencies achieve verified operational readiness.Cluster Boot [docker compose up]
│
├─────────────────────────────────┬─────────────────────────────────┐
▼ ▼ ▼
[Postgres: Starting daemon] [Redis: Allocating heap] [MinIO: S3 API Boot]
│ │ │
[pg_isready: Rejecting...] [redis-cli ping: PONG!] [curl /minio/health: 200]
│ │ │
[pg_isready: 200 OK!] ▼ ▼
│ Status: HEALTHY Status: HEALTHY
▼ │ │
Status: HEALTHY │ │
│ │ │
└─────────────────────────────────┼─────────────────────────────────┘
│ All health gates cleared
▼
[minio-init: Provisioning S3 buckets]
│ Exit code 0
▼
[web (Next.js): Application Boot]
• Run Prisma/Drizzle schema migrations
• Establish connection pools to Postgres/Redis
• Serve incoming client HTTP trafficA second critical concept is Dual-Endpoint S3 Resolution. Because MinIO interacts with both the Next.js backend (inside the Docker bridge network) and client web browsers (on the developer host), configurations must explicitly distinguish internal API requests (http://minio:9000) from client-facing Presigned upload URLs (http://localhost:9000).
Key orchestration primitives:
A newly onboarded engineer clones the codebase and executes docker compose up -d. Within seconds, a complete local ecosystem boots: a database initialized with schemas, a Redis cache, and an S3 bucket with download permissions—requiring zero local database installations or cloud credentials.
In GitHub Actions runners, Playwright end-to-end test suites spin up this complete Docker Compose topology. Tests run against genuine PostgreSQL instances and true S3 endpoints rather than inaccurate mocks or in-memory emulators.
For defense or healthcare installations under strict data sovereignty mandates (where connecting to AWS or GCP public clouds is prohibited), this stack deploys seamlessly as an air-gapped production topology on dedicated enterprise servers.
We construct a production-ready compose.yaml combining all four services with explicit health checks, persistent volumes, and a bucket provisioning sidecar:
Provision isolated bridge network backend-net and establish persistent named storage volumes.
Boot Postgres, Redis, and MinIO in parallel, holding upstream dependants until health probes pass.
Execute minio/mc sidecar container to configure alias, create app-uploads bucket, and set anonymous download policy.
The web container launches once upstream infrastructure is healthy, binding port 3000 to the host.
compose.yaml Manifest#Adhering to the modern Compose v2 specification (where the version: top-level tag is deprecated):
name: agenticlab-fullstack
services:
# ── 1. POSTGRESQL 17 PERSISTENCE ENGINE ──
postgres:
image: postgres:17-alpine
container_name: fullstack-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres_dev_secret}
POSTGRES_DB: ${POSTGRES_DB:-agenticlab_db}
ports:
# Expose to host for IDE / database GUI access (DBeaver / DataGrip)
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- backend-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-agenticlab_db}"]
interval: 5s
timeout: 5s
retries: 5
start_period: 5s
# ── 2. REDIS 7 IN-MEMORY CACHE ──
redis:
image: redis:7.4-alpine
container_name: fullstack-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:-redis_dev_secret}"]
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- backend-net
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-redis_dev_secret}", "ping"]
interval: 5s
timeout: 3s
retries: 5
start_period: 3s
# ── 3. MINIO S3 OBJECT STORAGE ──
minio:
image: minio/minio:RELEASE.2024-11-07T00-52-20Z
container_name: fullstack-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY:-minio_admin}
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:-minio_secret_password}
ports:
- "9000:9000" # S3 REST API endpoint
- "9001:9001" # MinIO Web Console UI
volumes:
- minio_data:/data
networks:
- backend-net
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 5
start_period: 5s
# ── 4. S3 BUCKET INITIALIZER (SIDECAR) ──
minio-init:
image: minio/mc:RELEASE.2024-11-05T11-41-28Z
container_name: fullstack-minio-init
depends_on:
minio:
condition: service_healthy
networks:
- backend-net
entrypoint: >
/bin/sh -c "
echo 'Configuring MinIO client alias...';
/usr/bin/mc alias set myminio http://minio:9000 ${S3_ACCESS_KEY:-minio_admin} ${S3_SECRET_KEY:-minio_secret_password};
echo 'Provisioning app-uploads bucket...';
/usr/bin/mc mb myminio/app-uploads --ignore-existing;
echo 'Setting public read policy for media...';
/usr/bin/mc anonymous set download myminio/app-uploads;
echo 'MinIO initialization complete.';
exit 0;
"
# ── 5. NEXT.JS 15 APPLICATION (WEB) ──
web:
build:
context: .
dockerfile: Dockerfile
target: runner
container_name: fullstack-web
restart: unless-stopped
ports:
- "3000:3000"
environment:
NODE_ENV: production
PORT: 3000
# Internal DNS names within the backend-net bridge network
DATABASE_URL: "postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres_dev_secret}@postgres:5432/${POSTGRES_DB:-agenticlab_db}?schema=public"
REDIS_URL: "redis://:${REDIS_PASSWORD:-redis_dev_secret}@redis:6379"
# S3 endpoint for server-side Node.js SDK calls
S3_ENDPOINT: "http://minio:9000"
# Public S3 endpoint for client browser downloads & presigned uploads
NEXT_PUBLIC_S3_PUBLIC_URL: "http://localhost:9000"
S3_BUCKET: "app-uploads"
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-minio_admin}
S3_SECRET_KEY: ${S3_SECRET_KEY:-minio_secret_password}
S3_REGION: "us-east-1"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio-init:
condition: service_completed_successfully
networks:
- backend-net
networks:
backend-net:
driver: bridge
volumes:
postgres_data:
name: fullstack_postgres_data
redis_data:
name: fullstack_redis_data
minio_data:
name: fullstack_minio_dataWe configure the official @aws-sdk/client-s3. Server-side functions communicate over http://minio:9000, while generated presigned URLs are rewritten to http://localhost:9000 for client browser uploads:
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
// Server-side S3 client executing inside the Docker network (Next.js -> MinIO)
export const s3ServerClient = new S3Client({
region: process.env.S3_REGION || 'us-east-1',
endpoint: process.env.S3_ENDPOINT || 'http://minio:9000',
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY || '',
secretAccessKey: process.env.S3_SECRET_KEY || '',
},
// Essential for MinIO: enforce path-style addressing (http://host/bucket) instead of sub-domains
forcePathStyle: true,
});
/**
* Generates an authorized Presigned URL allowing secure direct browser uploads
*/
export async function createPresignedUploadUrl(
fileKey: string,
contentType: string,
expiresInSeconds = 300
): Promise<{ uploadUrl: string; finalPublicUrl: string }> {
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET || 'app-uploads',
Key: fileKey,
ContentType: contentType,
});
// Generate internal Presigned URL
const rawUrl = await getSignedUrl(s3ServerClient, command, {
expiresIn: expiresInSeconds,
});
// Rewrite internal minio:9000 host to the public host accessible by client browsers
const publicOrigin = process.env.NEXT_PUBLIC_S3_PUBLIC_URL || 'http://localhost:9000';
const internalOrigin = process.env.S3_ENDPOINT || 'http://minio:9000';
const uploadUrl = rawUrl.replace(internalOrigin, publicOrigin);
const finalPublicUrl = `${publicOrigin}/${process.env.S3_BUCKET}/${fileKey}`;
return { uploadUrl, finalPublicUrl };
}Declaring depends_on: [postgres] in compose.yaml. Upon running docker compose up, the web container immediately crashes with PrismaClientInitializationError: Can't reach database server at postgres:5432.
Root Cause: Assuming depends_on awaits full daemon initialization.
Production Reality: The web service starts before Postgres is ready to accept incoming TCP handshakes.
# ❌ INCORRECT: Only verifies container creation, not operational readiness
depends_on:
- postgres
# ✅ CORRECT: Suspends startup until database healthcheck passes
depends_on:
postgres:
condition: service_healthyThe client browser attempts to upload a file to the URL returned by a Server Action: PUT http://minio:9000/app-uploads/photo.jpg and fails with net::ERR_NAME_NOT_RESOLVED.
Root Cause: The AWS SDK populates the URL using the endpoint configured in S3Client, ignoring host network boundaries.
Production Reality: The user's browser runs outside the Docker bridge network and cannot resolve the internal DNS name minio. The URL must point to localhost:9000.
// ❌ INCORRECT: Generates unreachable internal container host URL
const uploadUrl = await getSignedUrl(s3Client, command); // returns http://minio:9000/...
// ✅ CORRECT: Rewrites internal origin to the publicly accessible host URL
const uploadUrl = rawUrl.replace(process.env.S3_ENDPOINT, process.env.NEXT_PUBLIC_S3_PUBLIC_URL);Queries dispatched to MinIO trigger ENOTFOUND app-uploads.minio.
Root Cause: The AWS SDK defaults to virtual-hosted style URLs (http://bucket-name.s3.amazonaws.com).
Production Reality: The SDK queries sub-domains (app-uploads.minio) unsupported by Docker's internal DNS. MinIO requires path-style addressing (http://minio:9000/app-uploads).
// ❌ INCORRECT: Defaults to forcePathStyle = false
const client = new S3Client({ endpoint: 'http://minio:9000' });
// ✅ CORRECT: Explicitly enabling path-style addressing
const client = new S3Client({
endpoint: 'http://minio:9000',
forcePathStyle: true,
});Using ./data/postgres:/var/lib/postgresql/data instead of named volumes postgres_data:/var/lib/postgresql/data.
Root Cause: Desiring immediate visibility of raw database files in workstation IDEs.
Production Reality: Severe I/O performance degradation caused by filesystem translation layers (WSL2 / virtiofs), accompanied by file permission errors and PostgreSQL WAL page corruption.
# ❌ ANTI-PATTERN: Host bind mount for databases
volumes:
- ./local-db-data:/var/lib/postgresql/data
# ✅ CORRECT: Native named volume with Linux performance
volumes:
- postgres_data:/var/lib/postgresql/dataDefine database passwords, Redis tokens, and MinIO credentials inside .env.
Execute docker compose up -d to create the bridge network and attach named volumes.
Compose blocks Next.js until Postgres and Redis health probes return healthy status.
The minio-init sidecar creates app-uploads and sets public anonymous download policies.
The web container initializes on port 3000, connecting seamlessly to verified backend stores.