Full setup from local development to production deployment on a DigitalOcean Droplet.
my-project/
├── Dockerfile.prod ← production image (deps → builder → runner)
├── docker-compose.prod.yml ← production orchestration (nginx + app + db)
├── .env ← dev credentials · never committed
├── .env.example ← placeholder values · committed to repo
├── .env.production ← prod credentials · lives on server only
├── .gitignore ← ignores .env · keeps .env.example
├── next.config.ts ← output: standalone (required for prod image)
├── nginx/
│ └── nginx.conf ← reverse proxy · SSL termination
└── .devcontainer/
├── devcontainer.json ← VS Code entry point · extensions · settings
├── Dockerfile ← dev image (base → builder → devcontainer)
└── docker-compose.yml ← dev orchestration (app + db)
- Open VS Code
F1→ Dev Containers: Clone Repository in Container Volume- Paste your repo URL
- VS Code builds the image, starts services, clones the repo, runs
pnpm install
For an existing local project:
F1→ Dev Containers: Reopen in Container
| Service | What it is | Internal host | Host port |
|---|---|---|---|
app |
Node 22 / TypeScript dev environment | — | 3000 |
db |
PostgreSQL 16 (Alpine) | db:5432 |
8001 |
Stage 1 — base
node:22-bookworm-slim
OS packages: git, curl, jq, ca-certificates
non-root user: node_user (UID 1001)
Stage 2 — builder
inherits base
build tools: gcc, g++, make, python3 (for native addons)
global CLIs: pnpm, typescript, ts-node (via corepack)
Stage 3 — devcontainer ← what runs
inherits base
symlinks globals from builder
switches to node_user
exposes port 3000
| Volume | What it persists |
|---|---|
postgres-data |
PostgreSQL data — survives rebuilds |
devcontainer-bashhistory |
Terminal history — survives rebuilds |
| workspace (VS Code managed) | The cloned repo — not declared in compose |
Services communicate over the default compose network.
From inside the app container the database is at db:5432 — not localhost.
DATABASE_URL=postgresql://postgres:postgres@db:5432/dev
Port 8001 on host → 5432 inside db container.
Connect from host tools (TablePlus, pgAdmin) at localhost:8001.
# rebuild image from scratch
docker compose -f .devcontainer/docker-compose.yml build --no-cache
# stop all services (keeps containers)
docker compose -f .devcontainer/docker-compose.yml stop
# stop and remove containers + networks (keeps volumes)
docker compose -f .devcontainer/docker-compose.yml down
# wipe everything including database
docker compose -f .devcontainer/docker-compose.yml down --volumes
# shell into app container
docker exec -it <container_name> bash
# list running containers
docker psStage 1 — deps
node:22-bookworm-slim
pnpm install --frozen-lockfile --prod
production dependencies only
Stage 2 — builder
full pnpm install (includes devDependencies)
pnpm build → next build
produces .next/standalone/
Stage 3 — runner ← what ships
starts fresh from node:22-bookworm-slim
copies .next/standalone from builder
copies .next/static from builder
copies public/ from builder
non-root user: nextjs (UID 1001)
CMD: node server.js
next.config.tsmust haveoutput: "standalone"for the runner stage to work.
| Service | Role | Exposed |
|---|---|---|
nginx |
Reverse proxy · SSL termination | ports 80 + 443 (internet) |
app |
Next.js standalone server | port 3000 (internal only) |
db |
PostgreSQL 16 | never exposed |
internet → nginx:443 → app:3000 → db:5432
nginx is the only service that touches the internet. app and db are sealed inside the compose network.
Variables prefixed NEXT_PUBLIC_ are baked into the client bundle at build time.
They must be passed as build arguments during docker build:
docker build \
--build-arg NEXT_PUBLIC_APP_URL=https://yourdomain.com \
-f Dockerfile.prod .Non-public variables (DATABASE_URL etc.) are runtime — they come from .env.production.
The app service healthcheck hits GET /api/health.
Add this route to your Next.js app:
// app/api/health/route.ts
export async function GET() {
return Response.json({ status: "ok" });
}NODE_ENV=development
POSTGRES_USER=postgres
POSTGRES_PASSWORD=changeme
POSTGRES_DB=dev
DATABASE_URL=postgresql://postgres:changeme@db:5432/devNODE_ENV=
POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_DB=
DATABASE_URL=NODE_ENV=production
NEXT_PUBLIC_APP_URL=https://yourdomain.com
POSTGRES_USER=youruser
POSTGRES_PASSWORD=astrongpassword
POSTGRES_DB=yourdbname
DATABASE_URL=postgresql://youruser:astrongpassword@db:5432/yourdbname
APP_IMAGE=ghcr.io/yourname/my-app:latestSSH into a fresh Ubuntu 24 droplet and install Docker:
apt-get update && apt-get upgrade -y
curl -fsSL https://get.docker.com | sh
docker --version
docker compose versionAdd A records in your domain registrar:
Type Name Value
A @ your.droplet.ip
A www your.droplet.ip
Wait for DNS propagation before running certbot.
# from your local machine
scp docker-compose.prod.yml root@your.droplet.ip:~/app/
scp -r nginx/ root@your.droplet.ip:~/app/
scp .env.production root@your.droplet.ip:~/app/The droplet only needs:
~/app/
├── docker-compose.prod.yml
├── .env.production
└── nginx/
└── nginx.conf
Temporarily comment out the 443 server block in nginx.conf, then:
# start nginx on port 80 only
docker compose -f docker-compose.prod.yml up -d nginx
# run certbot
docker run --rm \
-v ./nginx/certbot/conf:/etc/letsencrypt \
-v ./nginx/certbot/www:/var/www/certbot \
certbot/certbot certonly --webroot \
--webroot-path /var/www/certbot \
-d yourdomain.com \
-d www.yourdomain.com \
--email you@email.com \
--agree-tosOnce the certificate is issued, uncomment the 443 block.
cd ~/app
# log in to container registry
docker login ghcr.io -u yourname
# pull image and start everything
docker compose -f docker-compose.prod.yml up -d# check all services are running
docker compose -f docker-compose.prod.yml ps
# follow all logs
docker compose -f docker-compose.prod.yml logs -f
# follow app logs only
docker compose -f docker-compose.prod.yml logs -f app# pull the new image
docker compose -f docker-compose.prod.yml pull
# restart app only — nginx and db keep running
docker compose -f docker-compose.prod.yml up -d --no-deps appports in compose |
forwardPorts in devcontainer.json |
|
|---|---|---|
| Layer | Docker daemon | VS Code tunnel |
| Works without VS Code | Yes | No |
| Use in dev | Optional | Yes |
| Use in prod | Yes | Not applicable |
| Clone into volume | Bind mount | |
|---|---|---|
| Files live on | Docker volume | Host machine |
| Performance | Fast — no sync overhead | Slower on macOS/Windows |
| Host file access | No | Yes |
| Compose workspace volume | Not declared | - ..:/workspaces:cached |
Alternative networking where app shares db's network stack — making localhost:5432
work from inside app. Tradeoff: app cannot define its own ports mapping.
The standard approach (used here) is explicit port mapping + db hostname.
Let's Encrypt certificates expire every 90 days. Set up auto-renewal on the droplet:
# add to crontab
0 0 * * * docker run --rm \
-v ~/app/nginx/certbot/conf:/etc/letsencrypt \
-v ~/app/nginx/certbot/www:/var/www/certbot \
certbot/certbot renew --quiet && \
docker compose -f ~/app/docker-compose.prod.yml exec nginx nginx -s reload- Prettier, ESLint
- Tailwind CSS IntelliSense
- ES7 React snippets, Auto Rename/Close Tag
- VSCode Icons, Dracula Theme
- Indent Rainbow, Color Highlight, Better Comments
- REST Client
- GitHub CLI (via devcontainer feature)
Pipeline file lives at .github/workflows/deploy.yml.
Runs on every push to main. No other branches trigger the pipeline.
push to main
└── lint-and-test
└── (if green) build-and-push
Jobs are sequenced via needs: lint-and-test — a failed lint or test blocks the build entirely.
Go to repo → Settings → Secrets and variables → Actions
| Name | Where to get it |
|---|---|
DOCKERHUB_USERNAME |
Your Docker Hub username |
DOCKERHUB_TOKEN |
Docker Hub → Account Settings → Security → New Access Token |
Go to repo → Settings → Secrets and variables → Actions → Variables
| Name | Example value |
|---|---|
NEXT_PUBLIC_APP_URL |
https://yourdomain.com |
Variables (not secrets) for non-sensitive values — visible in logs, safe to expose.
Every build produces two tags on Docker Hub:
| Tag | Example | Purpose |
|---|---|---|
latest |
yourname/my-app:latest |
Always points to the most recent build |
sha- |
yourname/my-app:sha-a1b2c3d |
Pinned to a specific commit — used for rollbacks |
# on the droplet — roll back to a specific commit
APP_IMAGE=yourname/my-app:sha-a1b2c3d \
docker compose -f docker-compose.prod.yml up -d --no-deps appBuild layers are cached in the Docker Hub registry between runs.
If package.json hasn't changed, the deps and builder install steps
are restored from cache — subsequent builds take under a minute.
# skip pipeline on README or devcontainer changes
on:
push:
branches:
- main
paths-ignore:
- '**.md'
- '.devcontainer/**'# disable password auth — key only
# in /etc/ssh/sshd_config
PasswordAuthentication no
PermitRootLogin no # create a non-root user for SSH insteadSecrets in build args We pass NEXT_PUBLIC_APP_URL as a build arg. Build args are visible in docker history on the image. For truly sensitive values never use ARG — use runtime environment variables instead. Public URLs are fine as build args, but API keys, tokens etc. should never go there.
# in CI — audit dependencies
- name: Audit dependencies
run: pnpm audit --audit-level=high
# or scan the built image
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: yourname/my-app:latest# in nginx.conf http block
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
# on sensitive locations
location /api/auth {
limit_req zone=api burst=20 nodelay;
proxy_pass http://app:3000;
}Using image digest pinning (image: yourname/my-app@sha256:abc...) instead of :latest on the droplet for verified deploys
- ufw firewall ← biggest immediate risk
- SSH hardening ← second biggest
- SSL auto-renewal ← time bomb if ignored
- pnpm audit in CI ← easy to add, good habit
- rate limiting ← when you have auth endpoints
- image scanning ← when the project matures