DEV Community

Kunal
Kunal

Posted on Originally published at kunalganglani.com

How to Set Up Self Hosted DevContainers [2026] (SSH + VS Code)

Originally published at kunalganglani.com — read it there for inline code, hero image, and live links.

You’ll finish this guide with a single VM (or homelab box) that feels like GitHub Codespaces. You connect over SSH from your laptop, VS Code opens your repo, and your project boots inside a pinned Dev Container with the same toolchain every time.

If you already have a VM and a domain, this is a 30–60 minute setup. The goal is boring. One remote box. One devcontainer.json. No “works on my machine” drift.

And yes, this is absolutely a self hosted devcontainers workflow. It’s the sweet spot between “everything local” and “some vendor’s cloud IDE.”

Here’s the whole plan:

  1. Provision one Ubuntu VM with enough CPU/RAM/disk to build images fast.
  2. Harden SSH like you mean it.
  3. Install Docker with BuildKit caching.
  4. Put a real devcontainer.json in your repo (pinned base image, pinned Features, non-root remoteUser).
  5. Validate the exact same container build in CI using the Dev Container CLI.

Here’s the official demo if you want to see the intended UX first:

[YOUTUBE:b1RavPr_878|Get Started with Dev Containers in VS Code]

What is self hosted devcontainers

Self hosted devcontainers is a workflow where you run Visual Studio Code Dev Containers on infrastructure you control (a VM, bare metal, or a homelab server) and connect to it remotely, typically over SSH. Everyone gets the same containerized toolchain without paying for hosted services like GitHub Codespaces.

Two clarifications that actually matter once you’re living with this:

  • “Self-hosted” does not mean “browser IDE.” You keep VS Code on your laptop. The compute runs remotely.
  • “Dev Containers” is not “a Dockerfile.” The source of truth is devcontainer.json and the spec around it.

I like this setup for the unglamorous reasons. Hosted dev environments are convenient until the bill shows up, the service flakes out, or legal asks where the data boundary is. A VM you own is predictable. That’s the whole point.

Getting started (single-VM blueprint)

If you want the simplest thing that works for a small team, do this:

  • 1 VM (Ubuntu 24.04 LTS is a solid default) running Docker and OpenSSH.
  • 1 Unix user per human (or per project if you want stricter separation).
  • Repos live on the VM (either in home directories or under /srv/dev/…).
  • Everything else lives in the Dev Container.

The directory layout I’ve found easiest to operate without getting cute:

  • /srv/dev/repos/<org>/<repo> for working copies
  • /srv/dev/docker-data for Docker state (or a dedicated volume)
  • /srv/dev/cache for optional shared caches (only if you trust the users)

Decide your backup story up front. Not philosophically. Literally: if this VM dies at 2am, what do you lose?

  • If your repos are just git clone plus container rebuild, you lose almost nothing.
  • If you’re storing local DB volumes or tool caches, back up /srv/dev nightly.

If you want the “Codespaces-like” vibe, aim for ephemeral rebuildability. Treat the VM like cattle. Your devcontainer.json is the pet.

System requirements (and why they’re different from “a server”)

You’re not running production. You’re running compilers, package managers, test suites, and Docker builds. The resource profile is different, and pretending it isn’t is how you end up with a sluggish “dev box” nobody wants to use.

My default sizing for a single developer remote dev box:

  • 4 vCPU minimum. 8 vCPU if you build often.
  • 16 GB RAM minimum. 32 GB if you run multiple services locally (Postgres + Redis + app + tests).
  • 150–300 GB SSD. Containers eat disk. Build caches eat disk. Node projects especially eat disk.

Two numbers that surprise people the first time:

  • One node_modules can be multiple GB.
  • Docker build cache grows quietly, then suddenly you’re out of disk.

For disk, I strongly prefer putting Docker’s data root on its own volume so you can resize it or wipe it without touching repos. If you do nothing else, at least look at disk usage weekly. “I didn’t notice” is the most common root cause of a broken dev server.

Minimum host requirements and constraints are defined by the Dev Containers tooling itself. The Development Containers Specification is the vendor-neutral baseline.

Installation (Docker, BuildKit, and VS Code extensions)

You need three things in the right places:

  1. On your laptop: VS Code + Remote Development extensions.
  2. On the VM: OpenSSH server + Docker Engine.
  3. In your repo: a .devcontainer/ folder with devcontainer.json.

Laptop setup

Install:

  • Remote - SSH
  • Dev Containers

This is the canonical workflow Microsoft documents: Remote-SSH + Dev Containers combined so you can “open a folder on a remote SSH host in a container” (VS Code Dev Containers docs).

VM setup

Install OpenSSH and Docker.

I’m intentionally not pasting 40 lines of distro-specific commands because it’s not the interesting part. The interesting parts are (1) not getting owned via SSH and (2) not waiting forever for images to build.

Two 2026-relevant defaults:

  • Make sure Docker is using BuildKit. It’s the modern build backend and it’s what makes cache mounts and remote caching tolerable. Docker’s docs are the right reference: BuildKit.
  • Learn how Docker caching actually works before you cargo-cult random flags. Start here: Docker build cache.

SSH host setup (hardening for an always-on dev box)

If your VM is reachable from the internet, treat SSH as a production surface. Because it is.

My baseline checklist:

  • Key-only auth. No passwords.
  • Disable root login.
  • Create a dedicated user per developer.
  • Firewall allow-list if you can (office IPs, VPN, or a jump host).
  • Fail2ban if you cannot.

sshd_config options worth caring about:

  • PasswordAuthentication no
  • PermitRootLogin no
  • PubkeyAuthentication yes

If you want a practical map of what these settings actually do, the sshd_config reference at SSH.COM is genuinely useful: SSH.COM Academy: sshd_config.

A take I’ll defend: changing the SSH port is fine, but it’s not hardening. It’s log noise reduction.

Forwarding ports safely

Port forwarding is where people accidentally punch holes.

Rules I use:

  • Default to forwarding to localhost only (127.0.0.1).
  • Don’t publish containers directly to 0.0.0.0 unless you’re intentionally exposing a service.
  • Be explicit in devcontainer.json about ports and their intent.

VS Code’s Remote-SSH docs cover the mechanics and the UX for forwarding/tunneling (Remote-SSH documentation). The security decision is still yours.

Should you run Docker rootless on a self-hosted dev server?

If this VM is single-user and you’re not running untrusted code on it, rootless is optional.

If it’s multi-user or you treat it like shared infra, I strongly prefer rootless Docker. Docker’s docs explain the model and tradeoffs: Docker rootless mode.

The practical trade:

  • Pro: smaller blast radius if someone escapes a container or abuses the daemon.
  • Con: extra friction around networking, privileged ports, and tooling assumptions.

If you stick with rootful Docker, at minimum be strict about who can access the Docker socket. docker group membership is effectively root. People gloss over this. They shouldn’t.

Connect to a remote host + open a folder on a remote SSH host in a container

This is the “it should feel like Codespaces” part.

The flow:

  1. In VS Code, connect to the VM using Remote - SSH.
  2. Open the repo folder on the VM.
  3. Run “Dev Containers: Reopen in Container”.

That third step is the moment everything clicks. You’re editing a remote filesystem, but your tooling is inside a container defined by your repo.

Microsoft documents this combined flow explicitly as “Open a folder on a remote SSH host in a container” in the Dev Containers guide (VS Code Dev Containers docs). It works. It’s just not marketed as “self-hosted Codespaces,” even though that’s basically what it is.

Do Dev Containers require Docker on the remote machine?

Yes, for this setup.

Your laptop is just the UI. The container runtime needs to live where the filesystem lives. That means Docker (or a compatible engine) on the remote host.

If you want “no Docker on the remote,” you’re talking about a different architecture (local containers + remote filesystem mounts, or a web IDE). Possible. Not what we’re doing here.

Create a devcontainer.json file (pin it, or you’re wasting your time)

If your devcontainer.json isn’t pinned, you don’t have reproducibility. You have a vibe.

The authoritative reference for what you can encode is the spec reference at containers.dev: Dev Container metadata reference.

Stuff I always encode:

  • A pinned base image tag (or digest if you’re serious)
  • Tool installation via Features, pinned to versions
  • A non-root remoteUser
  • Extensions and settings so IDE config travels with the repo

Concrete choices that prevent drift:

  • remoteUser: don’t run your dev shell as root unless you enjoy permission bugs.
  • Extensions: declare them once so onboarding isn’t “install these 12 things.”
  • Lifecycle scripts: use them to bootstrap dependencies deterministically.

Dev Container Features (and why I like them)

Dev Container Features are the right kind of abstraction. They let you compose tooling without maintaining a giant bespoke Dockerfile.

The catalog is here: Available Dev Container Features.

What to pin:

  • Feature source (the registry path)
  • Feature version

If you don’t pin, “latest” will quietly change and you will re-live 2017’s “npm install broke the build” era.

Lifecycle scripts (bootstrap without snowflakes)

Use lifecycle hooks for things that must happen at container create time, not build time.

Examples:

  • postCreateCommand: install dependencies
  • postStartCommand: start a background service

The JSON reference documents these lifecycle scripts and when they run (containers.dev JSON reference).

I like lifecycle scripts because they keep your Docker image cacheable while still giving you a predictable “container is ready” moment.

Minimum host requirements, port attributes, and remoteUser

Three spec areas that matter for teams:

  • Minimum host requirements: don’t assume every host supports everything. Document your baseline.
  • Port attributes: declare which ports are forwarded and what they represent.
  • remoteUser: make it explicit. Root-by-default is how you get files owned by root in shared repos.

All three are in the canonical reference (containers.dev JSON reference).

Speed: caching + pre-building images (the stuff most guides skip)

Remote devcontainers are either delightful or miserable. The difference is almost always caching.

How to speed up devcontainer builds with caching

Three high-leverage moves:

  1. Order Dockerfile steps so stable layers come first (system packages, language runtimes).
  2. Use BuildKit cache mounts for package managers.
  3. Persist dependency caches in volumes when it makes sense.

Even without getting fancy, Docker layer caching is your first win. Docker explains the model clearly in their cache docs (Docker build cache).

Concrete numbers, because hand-wavy performance talk is useless:

  • If your base image + OS packages take 1–2 minutes to build, caching that layer saves you every rebuild.
  • If npm ci takes ~90 seconds cold, a cache mount can drop repeat installs to seconds when lockfiles don’t change.

Pre-building dev container images

If multiple people use the same container, prebuild and push it to a registry.

This is not a luxury. It’s the difference between “new dev onboarded in 10 minutes” and “new dev watched Docker scroll for 45 minutes.”

VS Code supports pre-building images and referencing them in your config (VS Code Dev Containers docs). Treat your dev image like any other artifact.

Avoiding CI/IDE drift with Dev Container CLI

The most common Dev Containers failure mode is “it works in VS Code, but CI is different.”

Fix it by making CI run the same devcontainer build.

The Dev Container CLI is designed to create and manage devcontainers outside the VS Code UI (Dev Container CLI). That’s your parity tool.

What I do in practice:

  • Add a CI job that builds the devcontainer.
  • Fail the build if the devcontainer can’t build.
  • Optionally run unit tests inside it.

That one check prevents weeks of slow drift and weird onboarding bugs.

Managing extensions, secrets, limitations, and troubleshooting

This is the stuff that doesn’t break on day 1. It breaks on day 14.

Managing extensions

VS Code remote has two execution contexts:

  • Extensions that run locally (UI-ish)
  • Extensions that run remotely (language servers, tooling)

Remote-SSH docs explain how extension placement works and why some extensions behave strangely (Remote-SSH documentation).

Rule of thumb: if an extension needs to touch the filesystem or run binaries, it probably needs to be installed on the remote side, or declared in devcontainer.json.

Managing secrets safely (SSH agent, git credentials)

I’m strict about secrets in remote dev boxes:

  • Prefer SSH agent forwarding over copying private keys to the VM.
  • Avoid writing long-lived tokens to disk in the repo.
  • Use per-user credential stores on the VM if you must.

If you’re using AI coding tools in the same environment, treat secrets even more carefully. I wrote a full playbook on redaction and workflow guardrails in How to Redact Secrets in an AI Coding CLI Tool [2026].

Known limitations

A few limitations are structural, not bugs:

  • VS Code Server updates can lag or fail on weird distros.
  • Containers + file permissions + bind mounts can create annoying ownership issues.
  • Multi-user Docker on one host is inherently sharp.

Don’t build a “shared dev VM” for 50 engineers. That’s a platform team problem, and pretending otherwise is how you end up with a fragile pet server everyone fears touching.

Troubleshooting: common failure modes and quick fixes

The recurring ones:

  1. VS Code Server mismatch / broken install: delete the remote server directory and reconnect.
  2. Docker permission errors: your user can’t access the daemon, or you’re mixing root-owned files with non-root containers.
  3. Devcontainer rebuild loops: usually an unpinned Feature or a lifecycle script that’s not idempotent.

When you’re diagnosing, split the problem instead of guessing:

  • Can I SSH in and run commands?
  • Can I run Docker builds on the host?
  • Can VS Code attach without Dev Containers?
  • Does the devcontainer build outside VS Code with Dev Container CLI?

That’s how you turn “VS Code is haunted” into a real failure you can fix.


One data point from my own work that shapes how I think about this: building and maintaining the 25+ browser dev tools on this site forced me to care about reproducibility. When small environment differences break a build, you lose time in the dumbest way possible. A pinned devcontainer removes a huge class of that nonsense. You can see the tool catalog at /tools.

If you’re going to do self hosted devcontainers in 2026, do it like you’re building a product. Pin versions. Validate in CI. Harden SSH. And keep it to one VM until you’ve earned the complexity.

My prediction: teams that treat dev environments as disposable artifacts, built from devcontainer.json the way prod is built from IaC, will onboard faster than teams still passing around “install Node 20.11, then…” docs. The tooling is already here. The only question is whether you’ll actually run it like you mean it.


Originally published on kunalganglani.com

Top comments (0)