Forwarding and Securing Ports in DevContainers

Your app listens on a port inside the container, and you need to reach it from the host — but not expose more than you intend. This page shows how forwardPorts and portsAttributes surface exactly the ports you choose, label them, and control auto-forward behaviour, so port access is explicit and auditable rather than a wide-open default.

Port forwarding matters because a devcontainer is a network boundary, not just a filesystem sandbox. The service on 3000 inside the container is invisible to your browser until something bridges the container's network namespace to the host's localhost. The devcontainer tooling can do that bridging automatically the moment it detects a new listener, which is convenient but also means an incidental process — a debug endpoint, a metrics exporter, a second dev server someone added to a script — can become reachable without anyone deciding it should be. Declaring forwardPorts turns that implicit behaviour into a reviewed list: the config file becomes the single place that answers "what is reachable from my machine?"

Reach for this whenever a container runs more than one listener, whenever the project is shared across a team whose editors each apply their own auto-forward defaults, or whenever the same devcontainer.json also runs in Codespaces or a headless CLI build where an accidentally public port has real consequences. The mental model is a small allow-list guarded by a default-deny rule: forwardPorts names the ports you want, portsAttributes describes how each one behaves and what to call it, and otherPortsAttributes sets the policy for everything you did not name. Get those three keys right and port access stops being an emergent property of whatever happens to be listening and becomes a deliberate, diffable decision.

Prerequisites

You need a service listening in the container and the ports you intend to expose.

  • A running service in the devcontainer (e.g. a dev server on 3000).
  • The port numbers you actually need on the host.
  • devcontainer.json you can edit.

None of these keys start a service for you — they only govern what the tooling does with a port once something is already listening on it. That is the detail people get wrong: they add 3000 to forwardPorts, see nothing at http://localhost:3000, and assume the forward is broken, when in fact the dev server either never started or bound to an interface the forward cannot see. Before you touch the forwarding config, confirm the service is actually up inside the container and that you know which port it listens on, because forwardPorts is a mapping instruction, not a launcher. It is also worth knowing the numbers exactly rather than guessing: forwarding 8080 when your framework defaults to 5173 produces the same silent nothing, and the config gives you no error because an unlisted, unused port is a perfectly valid state.

Port prerequisitesYou need a listening service, the ports to expose, and the config to declare them.Servicelistens in containerforwardPortschoose portsportsAttributeslabel + policy

Step-by-Step Implementation

  1. Forward only the ports you need. List them explicitly rather than publishing everything.
{
  "forwardPorts": [3000, 5432],
  "remoteUser": "vscode"
}

Listing 3000 and 5432 explicitly does two things at once. It guarantees those ports are forwarded as soon as the container is up, so the web server and Postgres are reachable without waiting for auto-detection, and — more importantly — it documents intent. Anyone reading the config sees precisely two ports leave the container, and a reviewer can question a third the moment it appears in a diff. Keeping the array short is the whole point: every entry is a port you have consciously decided to expose, so resist the temptation to pad it with "might need it later" numbers. The remoteUser line is unrelated to forwarding but shown here to make the point that these keys live in the same flat devcontainer.json object; the forwarding properties are ordinary top-level keys, not a nested block you have to opt into.

  1. Label and control each port with portsAttributes.
{
  "portsAttributes": {
    "3000": { "label": "Web", "onAutoForward": "notify" },
    "5432": { "label": "Postgres", "onAutoForward": "silent" }
  }
}

The keys under portsAttributes are strings because a devcontainer port entry can also be a range or a named reference, so "3000" is quoted even though it looks numeric. The label turns an anonymous number in the ports view into "Web" and "Postgres", which sounds cosmetic until you have four services running and cannot remember which one lives on which port. The onAutoForward value is the behaviour half: notify on 3000 gives you a prompt when the web server comes up, because that is a port you want to open in a browser, while silent on 5432 forwards Postgres without interrupting you, because a database connection is something tools reach for in the background rather than something you click. Matching the policy to how you actually use each port is what keeps the notification stream meaningful instead of noise you learn to dismiss.

  1. Restrict unlisted ports so nothing forwards by surprise.
{
  "otherPortsAttributes": { "onAutoForward": "ignore" }
}

This single key is what turns the setup from "forward these and also whatever else shows up" into a default-closed posture. otherPortsAttributes applies to every port that is not named in portsAttributes, and setting its onAutoForward to ignore means an unexpected listener — a leftover debug server on 9229, a language server, a test runner that opens a socket — is detected but never bridged to the host. Without this line the tooling's built-in default leans toward forwarding, so the failure mode it prevents is the quiet one: a port you never reviewed becoming reachable simply because a process happened to bind it. Pairing an explicit forwardPorts allow-list with otherPortsAttributes: ignore is the belt-and-braces combination that makes the config, rather than runtime behaviour, the source of truth for exposure.

  1. Verify what is actually reachable from the host.
curl -sf http://localhost:3000 >/dev/null && echo "web up"

Verification closes the loop, because the config describing what should be reachable is not proof of what is. The -s flag keeps curl quiet, -f makes it return a non-zero exit status on an HTTP error instead of printing an error page, and redirecting to /dev/null discards the body so the command is purely a reachability probe. Chaining && echo "web up" means you only see the confirmation when the request genuinely succeeded, which distinguishes "the forward works and the app answered" from "the port is open but the server returned a 500". Run this from the host, not from inside the container, since a probe run inside the container tests the service directly and tells you nothing about whether the forward reached the host at all — the exact ambiguity that sends people chasing forwarding bugs that are really binding bugs.

Port control keysThree keys forward chosen ports, set per-port policy, and ignore the rest.forwardPortsexplicit list of exposed portsportsAttributesper-port label + onAutoForwardotherPortsAttributesdefault for everything elseResultonly intended ports reach the host

Common Pitfalls

Port problems are either over-exposure (too much forwarded) or a service that isn't actually reachable.

The most common and most confusing failure is the loopback-binding trap. A dev server that binds to 127.0.0.1 is listening only on the container's own loopback interface, so even a correctly declared forward has nothing to connect to from the host's perspective — the forward reaches the container's network namespace, but the service is deaf to anything arriving on that interface. The fix is to bind the service to 0.0.0.0 so it accepts connections on all interfaces, which most frameworks expose as a --host 0.0.0.0 flag or a HOST environment variable. Because the symptom (nothing at localhost:3000) is identical whether the port is unforwarded, the service is down, or the service is bound to loopback, it pays to check the binding first: it is the cause people overlook precisely because the forwarding config looks correct.

Over-exposure is the quieter pitfall because nothing appears broken. When auto-forward is left enabled for every port, each new listener silently becomes reachable, and in a shared Codespace or a port whose visibility was set to public that turns an internal-only endpoint into an accessible one. The remedy is the otherPortsAttributes: ignore default described above, treated as a standing rule rather than a reaction to a specific leak. Notification fatigue is the same problem wearing a different hat: if every port is set to notify, the prompts blur together and you stop reading them, so a genuinely surprising forward slides past unnoticed. Reserve notify and openBrowser for the handful of ports you interact with directly, and let background services forward silent, so the prompts you do get still mean something.

Port triageA triage path from over-exposure or an unreachable service to intended access only.Is a port exposed that shouldn't be?YESSet otherPortsAttributes: ignoreIs the needed port listed + service up?YESBind to 0.0.0.0, not 127.0.0.1Only intended ports reachable

SymptomRoot CauseRemediation
Port not reachable from hostService bound to 127.0.0.1 inside containerBind the service to 0.0.0.0
Unexpected port forwardedAuto-forward left on for all portsSet otherPortsAttributes onAutoForward ignore
Noisy forward notificationsEvery port set to notifyUse silent for background services
Port works locally, not in CodespacesPort visibility not setSet the port visibility appropriately

Conclusion

Forward explicitly and default to closed: list only the ports a task needs, label and set a policy on each, and ignore everything else. Explicit forwarding keeps port access auditable — you can read the config and know exactly what is reachable.

The strategic payoff is that exposure becomes a property of the repository rather than of a running session. Because forwardPorts, portsAttributes, and otherPortsAttributes live in devcontainer.json, they travel with the project through version control, code review, and every environment that consumes the same file — a colleague's editor, a Codespace, a headless CLI build in CI. That is the same reproducibility principle that governs pinning a base image or caching a dependency layer: you decide the behaviour once, commit it, and get the identical result everywhere instead of relying on whatever each machine defaults to. A port that is either explicitly forwarded or explicitly ignored is a port whose status you can reason about from the config alone, without SSHing in to run netstat.

Treated this way, port control folds naturally into the broader pin-and-cache discipline the rest of this section builds toward. Just as a pinned digest removes ambiguity about what runs and a warm cache removes ambiguity about how fast it builds, an explicit forwarding policy removes ambiguity about what is reachable. The three combine into an environment whose surface area you can describe in full from a single reviewed file. When a new service arrives, the change is a one-line addition to forwardPorts and a matching portsAttributes entry — small, visible in a diff, and easy to reason about — which is exactly the property you want a security-relevant setting to have.

Expose vs restrictForward only what a task needs and restrict everything else by default.ExposeOnly needed portsLabeled clearlySensible auto-forwardRestrictIgnore other portsBind services correctlySet visibility

FAQ

Why can't I reach my forwarded port? Usually because the service inside the container is bound to 127.0.0.1, which is only reachable from inside the container. Bind it to 0.0.0.0 so the forwarded port actually has something to connect to on all interfaces, then the host forward works. If the binding is already correct, work down the other candidates in order: confirm the service is actually running, confirm the port number in forwardPorts matches the port the service listens on, and run the curl probe from the host rather than from inside the container so you are testing the forward and not the service directly. The symptom is identical across all of these causes, so the fastest route to the answer is to eliminate them one at a time rather than guessing.

How do I stop random ports from being forwarded? Set otherPortsAttributes with onAutoForward set to ignore so any port you did not explicitly list is not forwarded. Combined with an explicit forwardPorts list, this gives you a default-closed posture where only intended ports reach the host. This is the single most valuable line for security, because it flips the tooling's instinct from "forward unless told otherwise" to "ignore unless told otherwise". Keep it in your base devcontainer.json as a standing default so every project inherits the closed posture and you only ever open ports deliberately.

What does onAutoForward control? It controls what happens when a port starts listening: notify pops a prompt, silent forwards without fanfare, openBrowser launches the browser, and ignore does not forward at all. Use silent for background services like databases and notify/openBrowser for the web port you actually open. Think of it as the behaviour dimension that sits alongside the label: label decides what a port is called, onAutoForward decides how loudly the tooling reacts when it appears. Choosing the value per port is what keeps the ports view calm — the databases and caches stay out of your way while the endpoints you interact with announce themselves.

Does forwardPorts publish ports to my whole network? No — forwarding bridges a container port to your host's localhost, not to your LAN. That is different from Docker's -p/ports: publishing, which binds a port on a host interface and can make it reachable from other machines. In Codespaces the analogous concern is port visibility: a forwarded port defaults to private (only you) and becomes reachable to others only if you explicitly set it to organization or public. So forwardPorts alone does not expose you to the network; it is visibility settings and Docker publish rules you need to watch for that.

Can I forward a port to a different number on the host? Yes. Instead of a bare number, use the "host:container" string form in forwardPorts, for example "3001:3000" to reach the container's 3000 at localhost:3001 on the host. This is the escape hatch for collisions — when something on your host already owns 3000, remap rather than fighting over the number. The portsAttributes key still references the container-side port, so the label and onAutoForward policy follow the service regardless of which host port you land it on.