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 →- Eliminates βworks on my machineβ β Every developer, CI runner, and production node shares the same base image and dependency set.
- Production-identical testing β Integration tests run against the exact same operating system packages, network constraints, and user permissions as production.
- Security by default β Containers enforce non-root users, minimal privileges, and read-only filesystems where appropriate. These settings are baked into the development image so they can't be missed later.
- Streamlined onboarding β A new team member only needs Docker, VSCode, and the repository; the dev container provisions everything else automatically.
- CI/CD alignment β The same Dockerfile used for development can be used as a build stage in CI, and the dev container CLI can run tests in a headless container identical to the developer's workspace.
Setting Up a Production-Grade Dev Container
Prerequisites
- Docker Desktop (or an equivalent Docker engine) installed and running.
- Visual Studio Code with the Remote β Containers extension installed.
- A project you want to containerize (we'll use a Node.js application as an example).
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.