Skip to content

DGwebdes/devcontainer-template-nodejs

Repository files navigation

Next.js / TypeScript — Dev & Production Template

Full setup from local development to production deployment on a DigitalOcean Droplet.


Project structure

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)

Development

How to open

  1. Open VS Code
  2. F1Dev Containers: Clone Repository in Container Volume
  3. Paste your repo URL
  4. VS Code builds the image, starts services, clones the repo, runs pnpm install

For an existing local project: F1Dev Containers: Reopen in Container

Dev services

Service What it is Internal host Host port
app Node 22 / TypeScript dev environment 3000
db PostgreSQL 16 (Alpine) db:5432 8001

Dev Dockerfile stages

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

Dev volumes

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

Dev networking

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.

Dev useful commands

# 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 ps

Production

Prod Dockerfile stages

Stage 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.ts must have output: "standalone" for the runner stage to work.

Prod services

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

Prod networking

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.

NEXT_PUBLIC_ variables

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.

Health check endpoint

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" });
}

Environment variables

.env (dev · never committed)

NODE_ENV=development
POSTGRES_USER=postgres
POSTGRES_PASSWORD=changeme
POSTGRES_DB=dev
DATABASE_URL=postgresql://postgres:changeme@db:5432/dev

.env.example (committed · no real values)

NODE_ENV=
POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_DB=
DATABASE_URL=

.env.production (lives on server only · never committed)

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:latest

First deployment — DigitalOcean Droplet

1. Provision the droplet

SSH 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 version

2. Point your domain

Add 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.

3. Copy files to the droplet

# 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

4. Get SSL certificate

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-tos

Once the certificate is issued, uncomment the 443 block.

5. Start all services

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

6. Verify

# 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

Subsequent deploys

# 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 app

Notes

ports vs forwardPorts

ports 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 vs bind mount

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

network_mode: service:db

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.

SSL certificate renewal

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

Extensions installed in dev container

  • 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)

CI Pipeline — GitHub Actions

Pipeline file lives at .github/workflows/deploy.yml.

Trigger

Runs on every push to main. No other branches trigger the pipeline.

Jobs

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.

Required GitHub Secrets

Go to repo → SettingsSecrets and variablesActions

Name Where to get it
DOCKERHUB_USERNAME Your Docker Hub username
DOCKERHUB_TOKEN Docker Hub → Account Settings → Security → New Access Token

Required GitHub Variables

Go to repo → SettingsSecrets and variablesActionsVariables

Name Example value
NEXT_PUBLIC_APP_URL https://yourdomain.com

Variables (not secrets) for non-sensitive values — visible in logs, safe to expose.

Image tags

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

Rolling back

# 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 app

Layer caching

Build 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.

Future additions

# skip pipeline on README or devcontainer changes
on:
  push:
    branches:
      - main
    paths-ignore:
      - '**.md'
      - '.devcontainer/**'

Security

# disable password auth — key only
# in /etc/ssh/sshd_config
PasswordAuthentication no
PermitRootLogin no        # create a non-root user for SSH instead

Secrets 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

  1. ufw firewall ← biggest immediate risk
  2. SSH hardening ← second biggest
  3. SSL auto-renewal ← time bomb if ignored
  4. pnpm audit in CI ← easy to add, good habit
  5. rate limiting ← when you have auth endpoints
  6. image scanning ← when the project matures

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors