Writing a devcontainer.json from scratch
What you're going to do
So far, you've opened and read devcontainer.json files someone else already wrote. This module flips that: you start from an empty folder and write, field by field, a complete devcontainer.json for a real small project — a minimal Node.js API. By the end, you rebuild the container and prove, from your host machine's browser, that the app actually runs inside it.
The project stays deliberately simple through this module and the next: a minimal Express API in Node.js. Keep this same project open — you'll add one more tool to it in module 2.
Step 1 — the project, before the container
Create the folder and initialize a minimal Node project:
mkdir my-node-api
cd my-node-api
npm init -y
npm install express
Then create index.js at the root:
const express = require("express");
const app = express();
const port = 3000;
app.get("/", (req, res) => {
res.send("Hello from inside the container!");
});
app.listen(port, () => {
console.log(`Listening on port ${port}`);
});
And a .gitignore with at least:
node_modules/
This matters for what follows: if node_modules were checked into version control, a rebuild would never prove that postCreateCommand actually reinstalled anything — you'd just be testing that a folder already there is still there. By excluding it, every rebuild starts from a project with no node_modules, and only postCreateCommand can make it reappear.
Step 2 — the devcontainer.json, field by field
Create .devcontainer/devcontainer.json:
{
"name": "My Node API",
"image": "mcr.microsoft.com/devcontainers/javascript-node:20",
"postCreateCommand": "npm install",
"forwardPorts": [3000],
"customizations": {
"vscode": {
"extensions": ["esbenp.prettier-vscode"]
}
}
}
image
We pick an already-built official image instead of a build with a local Dockerfile: mcr.microsoft.com/devcontainers/javascript-node:20 ships Node.js 20 preinstalled on a Debian base, which is plenty for an Express API. build (with a dockerfile and a context) is the alternative — useful when a public image doesn't cover your exact needs (a specific combination of system tools, for instance), but it's more maintenance for zero benefit here.
postCreateCommand
"npm install" runs automatically exactly once, right after the container is created — not on every start. This is what makes the project reproducible: anyone cloning this repo, opening the Dev Container, gets node_modules installed without typing a single command or reading a README that says "don't forget to run npm install."
forwardPorts
[3000] makes the container's port 3000 reachable from localhost:3000 on the host machine. Without this field, the Express server would run perfectly well inside the container, but stay invisible from the outside — you'd never be able to open it in your browser.
customizations.vscode.extensions
An array of extension identifiers installed automatically in the VS Code instance connected to the container. esbenp.prettier-vscode (Prettier) is relevant for a JavaScript project: everyone opening this Dev Container gets the same editor tooling, not just the same Node runtime.
Step 3 — rebuild and check
Open the folder in VS Code (code .), then from the command palette (Ctrl+Shift+P / Cmd+Shift+P): Dev Containers: Reopen in Container.
Once the window relaunches inside the container, open an integrated terminal and start the server:
node index.js
Expected output:
Listening on port 3000
Step 4 — the proof
Two checks — either one is enough, but do both to be sure:
- In VS Code, open the Ports tab (next to the terminal). Port 3000 should show up as forwarded. Click the globe icon next to it: your browser opens
http://localhost:3000and shows "Hello from inside the container!" - From a terminal on your host machine (not the container's), run
curl http://localhost:3000: you should get the same text, even though the Node process runs exclusively inside the container.
If both work, forwardPorts is doing its job: a process isolated inside a Linux container is reachable from your host machine, regardless of its operating system.
What you just did
You wrote a complete devcontainer.json from nothing: a base image chosen for a reason, a post-create command that makes the project reproducible for anyone else, a port opened to the host, and an extension shared across the whole team. In the next module, you keep this same project and add one more tool to it — not by editing the base image, but with a Dev Container Feature.
Check your understanding
Why put `npm install` in postCreateCommand instead of just running it once by hand after opening the container?
In devcontainer.json, which field makes a server listening on port 3000 inside the container reachable from the host machine's browser?
What is the correct syntax for automatically installing a VS Code extension inside the container?
This module picks `image` over `build`. Which statement is correct?
Want to hear about the next modules?
The Academy stays free and open-access, no sign-up required. If you'd just like to be notified by email when a new module ships, here you go — no obligation, unsubscribe anytime with one click.