← Back to DevBytes

Docker with VSCode Dev Containers: Production Guide

What Are Docker and VSCode Dev Containers?

VSCode Dev Containers (the "Remote – Containers" extension) let you open any folder inside a Docker container while retaining the full power of VSCode. Instead of installing compilers, runtimes, and toolchains directly on your host machine, you describe the entire development environment with a standard Dockerfile or Docker Compose file and a small JSON configuration. The extension builds the image, starts the container, mounts your source code, and attaches VSCode's backend. You then work inside the container exactly as if it were a local workspace, but with complete isolation and reproducibility.

In a production context, Dev Containers go far beyond simple convenience. They allow teams to develop, test, and debug applications inside an environment that is identical to the one that will run in staging and production. This guide treats Dev Containers as a first-class production tool β€” showing not just how to set them up, but how to harden them, keep them secure, and integrate them into a real delivery pipeline.

Why Dev Containers Matter for Production

πŸš€ Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Setting Up a Production-Grade Dev Container

Prerequisites

Step 1: Write a Production-Minded Dockerfile

Create a .devcontainer/Dockerfile (or place it at the project root if you prefer). This file should define the exact environment you'll run in production β€” same base OS, same runtime version, same security hardening.

# .devcontainer/Dockerfile
FROM node:20.11.1-alpine3.19 AS base

# Create a non-root user (production best-practice)
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# Set working directory and ownership
WORKDIR /home/appuser/app
RUN chown -R appuser:appgroup /home/appuser/app

# Install only essential system packages (curl for health checks, dumb-init for signal handling)
RUN apk add --no-cache \
    curl \
    dumb-init \
    && rm -rf /var/cache/apk/*

# Copy package files and install dependencies as root for efficiency,
# then switch to non-root user for runtime
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts \
    && chown -R appuser:appgroup node_modules

# Copy application code
COPY --chown=appuser:appgroup . .

# Set default runtime user
USER appuser

# Use dumb-init to handle signals correctly
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]

# Health check for production
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD curl -f http://localhost:3000/health || exit 1

Note that we deliberately omit development tools from the production image. Dev Containers allow layering additional tools on top without modifying this file, which we'll cover shortly.

Step 2: Create devcontainer.json

This file tells VSCode how to build and run your container, which extensions to install, and which development-only tools to add.

// .devcontainer/devcontainer.json
{
  "name": "Production Node.js App",
  "build": {
    "dockerfile": "Dockerfile",
    "context": "..",
    "args": {
      "NODE_ENV": "development"
    }
  },
  // Run as non-root user matching production
  "remoteUser": "appuser",
  "containerUser": "appuser",
  // Mount source code for live editing (production uses read-only rootfs, but dev needs write)
  "mounts": [
    "type=bind,source=${localWorkspaceFolder},target=/home/appuser/app"
  ],
  // Forward the production port
  "forwardPorts": [3000],
  // Environment variables for development (production gets them via orchestrator)
  "containerEnv": {
    "NODE_ENV": "development",
    "DATABASE_URL": "postgresql://user:pass@db:5432/mydb"
  },
  // Development-only layer with test runners, debuggers, linters
  "features": {
    "ghcr.io/devcontainers/features/node:1": {
      "version": "20",
      "additionalVersions": "none"
    },
    "ghcr.io/devcontainers/features/docker-in-docker:2": {
      "version": "latest"
    }
  },
  // VSCode extensions needed for development but not present in production
  "extensions": [
    "dbaeumer.vscode-eslint",
    "esbenp.prettier-vscode",
    "ms-azuretools.vscode-docker"
  ],
  // Run after container creation β€” typical setup scripts
  "postCreateCommand": "npm install && npm run dev-setup",
  // Keep the container running as long as VSCode is connected
  "shutdownAction": "stopContainer"
}

Key points: The build.args can pass environment-specific flags, but the base image remains production. The features property injects additional tools without polluting the Dockerfile. The postCreateCommand runs after the container is started, allowing you to install dev dependencies that won't appear in the final production image.

Step 3: Use Docker Compose for Multi-Service Setups

Production applications often depend on databases, caches, or message brokers. You can define the entire stack in a docker-compose.yml and reference it from devcontainer.json.

# docker-compose.yml (project root)
version: '3.8'
services:
  app:
    build:
      context: .
      dockerfile: .devcontainer/Dockerfile
      args:
        NODE_ENV: development
    user: appuser
    volumes:
      - .:/home/appuser/app
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgresql://appuser:secret@db:5432/mydb
      - REDIS_URL=redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    command: ["node", "--inspect=0.0.0.0:9229", "server.js"]

  db:
    image: postgres:16.2-alpine
    user: postgres
    environment:
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: mydb
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d mydb"]
      interval: 10s
      timeout: 5s
      retries: 5

  cache:
    image: redis:7.2-alpine
    user: redis
    volumes:
      - redisdata:/data

volumes:
  pgdata:
  redisdata:
// .devcontainer/devcontainer.json (using Docker Compose)
{
  "name": "Production App Stack",
  "dockerComposeFile": "../docker-compose.yml",
  "service": "app",
  "workspaceFolder": "/home/appuser/app",
  "remoteUser": "appuser",
  "forwardPorts": [3000, 9229],
  "extensions": [
    "dbaeumer.vscode-eslint",
    "ms-azuretools.vscode-docker"
  ],
  "features": {
    "ghcr.io/devcontainers/features/node:1": { "version": "20" }
  },
  "postCreateCommand": "npm install",
  "shutdownAction": "stopCompose"
}

Now Reopen in Container will bring up PostgreSQL, Redis, and the Node app in a production-like topology. All services run as non-root users, with health checks and proper volume persistence β€” exactly as they would in a real deployment.

Best Practices for Production Dev Containers

Pin Exact Versions of Base Images

Use tags like node:20.11.1-alpine3.19 instead of node:latest. This prevents unpredictable changes when rebuilding and guarantees that dev, CI, and production are truly identical.

Run as a Non-Root User

Always create a dedicated user in the Dockerfile and set it as the default. In devcontainer.json, explicitly set "remoteUser" and "containerUser" to the same user. This catches permission problems early and satisfies Kubernetes security policies that forbid root containers.

Minimize Image Size and Attack Surface

Remove package caches after installation (rm -rf /var/cache/apk/* for Alpine, apt-get clean for Debian). Avoid installing compilers, header files, or build tools in the final image. Use multi-stage builds when you need to compile artifacts β€” the dev container can use a separate "build" stage but keep the "production" stage lean.

Keep Development Tooling Out of the Production Image

Use the features property of devcontainer.json to inject test frameworks, debuggers, and linters at container start time. These layers are applied on top of the production image only during development. They never appear in the image that gets pushed to the registry.

Use .dockerignore to Exclude Secrets and Bloat

Create a .dockerignore file at the project root to prevent sensitive files, node_modules, and Git history from being copied into the image.

# .dockerignore
.env
*.log
.git
.github
node_modules
Dockerfile*
docker-compose*
.vscode

Mount Source Code as a Bind Mount, Don't Embed It

The production Dockerfile copies the application code into the image, but during development you want live changes. In devcontainer.json, mount the workspace with a bind mount that overlays the image's /home/appuser/app directory. This keeps the image immutable while giving you a fast edit-save-refresh cycle.

Integrate Health Checks from Day One

Define a HEALTHCHECK instruction in the Dockerfile and, for Compose services, add the equivalent healthcheck block. This forces developers to think about readiness probes early, and it allows orchestration tools to properly order service startup.

Use Environment Variables, Never Hardcode Secrets

Pass configuration via containerEnv or a .env file mounted at container runtime. Never bake credentials into the image. In production, inject secrets via a secrets manager or Kubernetes secrets; the development setup should mirror that pattern.

Test the Production Image Locally with the Dev Container CLI

The devcontainer CLI (part of the extension) allows you to build and run the container in a headless mode, ideal for CI. Run integration tests directly inside the production-identical environment:

# In CI pipeline
devcontainer build --workspace-folder . --image-name prod-app:test
devcontainer run --workspace-folder . --image-name prod-app:test -- bash -c "npm run test:integration"

This guarantees that tests execute in the exact same environment that will ship to production.

Leverage Cache Mounts for Faster Rebuilds

Use Docker BuildKit cache mounts in the Dockerfile for directories like /root/.npm (if you briefly install as root). This speeds up iterative development without affecting the final image size.

# Example: cache npm during install (runs as root, then chown)
RUN --mount=type=cache,target=/root/.npm,uid=1000,gid=1000 \
    npm ci --omit=dev --ignore-scripts \
    && chown -R appuser:appgroup node_modules

Conclusion

Docker and VSCode Dev Containers are far more than a convenience for local development. When configured with production discipline β€” exact version pinning, non-root users, minimal images, health checks, and a clear separation of development tooling β€” they become a powerful, production-tested environment that every team member shares. The same Dockerfile that you debug in VSCode today becomes the image that passes through CI and deploys to your cluster tomorrow. By adopting these practices, you eliminate environment drift, strengthen your security posture, and dramatically reduce the time from a developer’s first commit to a confident production release. Start with the patterns above, and you'll have a truly production-ready development container workflow that scales with your team.

πŸš€ Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started β€” $23.99/mo
← Back to all articles