Loading
Containerize a Next.js app with PostgreSQL using Docker and Docker Compose, then deploy to a free cloud platform.
Docker eliminates "works on my machine" problems by packaging your application and its dependencies into a portable container. In this tutorial, you'll take a Next.js application with a PostgreSQL database, containerize both services, orchestrate them with Docker Compose, and deploy the stack to Railway.
What you'll learn:
Prerequisites: Docker Desktop installed, a free Railway account, and basic familiarity with Next.js.
We'll work with a minimal Next.js app that connects to PostgreSQL. Create the project:
Create lib/db.ts:
Create app/api/notes/route.ts:
Create Dockerfile in the project root:
This multi-stage build is critical for production images. The final stage contains only the runtime and built output — no devDependencies, no source code, no build tools. A typical Next.js image drops from ~1.5GB to ~150MB.
Enable standalone output in next.config.ts:
Create .dockerignore:
This prevents copying unnecessary files into the build context. Without it, COPY . . sends your entire node_modules directory to the Docker daemon — wasting time and potentially leaking secrets.
Create docker-compose.yml:
Key details: the app service references the database as db (the service name), not localhost. Docker Compose creates a network where services resolve each other by name. The depends_on with condition: service_healthy ensures the database is actually accepting connections before the app starts — not just that the container is running.
Useful Docker commands during development:
Create docker-compose.override.yml for local development overrides:
This mounts your source code into the container, so changes trigger Next.js hot reload without rebuilding the image. The anonymous volumes for node_modules and .next prevent your host copies from overriding the container's versions.
For production, use an .env.production file (never committed):
Reference it in docker-compose.prod.yml:
Check your image size:
Further optimizations:
dumb-init handles signal forwarding correctly — without it, SIGTERM from Docker won't gracefully shut down your Node.js process, leading to dropped connections during deploys.
Add a .dockerignore entry for test files:
For production, you need proper migrations instead of initSchema(). Create scripts/migrate.ts:
Create migrations/001_create_notes.sql:
Railway provides a free tier that supports Docker deployments with PostgreSQL.
Railway automatically detects your Dockerfile and builds the image. The PostgreSQL plugin provisions a managed database with automatic backups.
Alternatively, create a railway.json for explicit configuration:
Before declaring your deployment production-ready, verify each item:
Production hardening checklist:
node:20.11-alpine not node:20-alpine)deploy.resources.limits.memory)logging.options.max-size)read_only: true on the app container's root filesystemYou now have a containerized application stack that runs identically on your laptop, in CI, and in production. The Dockerfile, Compose file, and deployment config are the three artifacts that fully describe your infrastructure.
npx create-next-app@latest docker-app --typescript --tailwind --app --no-src-dir
cd docker-app
npm install pg @types/pgimport { Pool, QueryResult } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/app",
});
export async function query(text: string, params?: unknown[]): Promise<QueryResult> {
const client = await pool.connect();
try {
return await client.query(text, params);
} finally {
client.release();
}
}
export async function initSchema(): Promise<void> {
await query(`
CREATE TABLE IF NOT EXISTS notes (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
content TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
}import { NextResponse } from "next/server";
import { query, initSchema } from "@/lib/db";
export async function GET(): Promise<NextResponse> {
try {
await initSchema();
const result = await query("SELECT * FROM notes ORDER BY created_at DESC");
return NextResponse.json(result.rows);
} catch (error) {
console.error("Database error:", error);
return NextResponse.json({ error: "Database error" }, { status: 500 });
}
}
export async function POST(request: Request): Promise<NextResponse> {
try {
await initSchema();
const { title, content } = await request.json();
const result = await query("INSERT INTO notes (title, content) VALUES ($1, $2) RETURNING *", [
title,
content,
]);
return NextResponse.json(result.rows[0], { status: 201 });
} catch (error) {
console.error("Database error:", error);
return NextResponse.json({ error: "Database error" }, { status: 500 });
}
}node_modules
.next
.git
.gitignore
*.md
docker-compose*.yml
.env*.local**/*.test.ts
**/*.spec.ts
__tests__
coverageimport type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
app:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://postgres:postgres@db:5432/app
depends_on:
db:
condition: service_healthy
volumes:
pgdata:# Build and start both services
docker compose up --build
# In another terminal, test it
curl http://localhost:3000/api/notes
curl -X POST http://localhost:3000/api/notes \
-H "Content-Type: application/json" \
-d '{"title":"First note","content":"From Docker!"}'# View logs
docker compose logs -f app
# Rebuild just the app (after code changes)
docker compose up --build app
# Stop everything
docker compose down
# Stop and destroy volumes (resets database)
docker compose down -v
# Shell into a running container
docker compose exec app sh
# Check image sizes
docker images | grep docker-appservices:
app:
build:
target: builder
command: npm run dev
volumes:
- .:/app
- /app/node_modules
- /app/.next
environment:
DATABASE_URL: postgres://postgres:postgres@db:5432/app
NODE_ENV: developmentDATABASE_URL=postgres://user:password@host:5432/dbnameservices:
app:
env_file:
- .env.productiondocker images docker-app-appimport { Pool } from "pg";
import fs from "fs";
import path from "path";
async function migrate(): Promise<void> {
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const client = await pool.connect();
try {
await client.query(`
CREATE TABLE IF NOT EXISTS migrations (
id SERIAL PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
applied_at TIMESTAMPTZ DEFAULT NOW()
)
`);
const migrationsDir = path.join(__dirname, "../migrations");
const files = fs.readdirSync(migrationsDir).sort();
for (const file of files) {
const applied = await client.query("SELECT id FROM migrations WHERE name = $1", [file]);
if (applied.rows.length === 0) {
const sql = fs.readFileSync(path.join(migrationsDir, file), "utf-8");
await client.query("BEGIN");
await client.query(sql);
await client.query("INSERT INTO migrations (name) VALUES ($1)", [file]);
await client.query("COMMIT");
console.log(`Applied: ${file}`);
}
}
} finally {
client.release();
await pool.end();
}
}
migrate().catch(console.error);CREATE TABLE IF NOT EXISTS notes (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
content TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW()
);# Install Railway CLI
npm install -g @railway/cli
# Login and initialize
railway login
railway init
# Add a PostgreSQL database
railway add --plugin postgresql
# Set the DATABASE_URL to reference the Railway-provided variable
railway variables set DATABASE_URL='${{Postgres.DATABASE_URL}}'
# Deploy
railway up{
"$schema": "https://railway.app/railway.schema.json",
"build": {
"builder": "DOCKERFILE",
"dockerfilePath": "Dockerfile"
},
"deploy": {
"healthcheckPath": "/api/notes",
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 3
}
}# Check the image runs correctly with production env
docker compose -f docker-compose.yml -f docker-compose.prod.yml up --build
# Verify the health check works
curl -f http://localhost:3000/api/notes || echo "Health check failed"
# Check container resource usage
docker stats --no-stream
# Verify no secrets in the image
docker history docker-app-app --no-trunc | grep -i secret# Stage 1: Install dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
# Stage 2: Build the application
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Stage 3: Production runtime
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]# Add to the runner stage to reduce image size
RUN apk add --no-cache dumb-init
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]