Rust Dev Containers for VS Code and Reproducible Setup
A dev container puts the toolchain, system libraries and services in a Docker image that the editor attaches to. A new contributor opens the folder and everything works.

A dev container is a development environment described in files and run in a container: the compiler, the system libraries, the debugger and the editor extensions all come from the repository, so a new contributor, a CI job and your laptop in a year all start from the same place. For Rust the payoff is concrete. The toolchain version stops drifting, native dependencies such as OpenSSL headers are installed once in a Dockerfile rather than in a wiki page, and "works on my machine" turns into a question about one image. This guide builds a Rust dev container for a small crate, runs the toolchain inside it, and reports what happened, including a permissions failure on a fresh cache volume that cost a build and the fix. Versions: Docker 29.9.2, the official rust:1.99-slim-bookworm image (rustc 1.99.1), on an Apple M1 Max with Docker Desktop. One limit up front: VS Code and the Dev Containers extension are not available on this machine, so I did not open the folder in an editor. Everything the editor would do with devcontainer.json (build the image, mount the volumes, run the commands) I ran by hand with the Docker CLI, and the guide marks the one place that depends on the editor.
What goes in the repository
The specification is a folder called .devcontainer with two files. The Dockerfile builds the image, and devcontainer.json tells the tooling how to run it: which user, which mounts, which extensions, which command to run after creation. Keeping the image definition separate from the runtime settings matters, because the Dockerfile can then be used unchanged by CI.
FROM rust:1.99-slim-bookworm
ARG USERNAME=dev
ARG UID=1000
RUN apt-get update \
&& apt-get install -y --no-install-recommends git pkg-config libssl-dev gdb ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --uid ${UID} --shell /bin/bash ${USERNAME} \
&& mkdir -p /usr/local/cargo/registry /workspace /workspace-target \
&& chown -R ${USERNAME}:${USERNAME} /usr/local/cargo /workspace /workspace-target
USER ${USERNAME}
ENV CARGO_TARGET_DIR=/workspace-target
RUN rustup component add rustfmt clippyRead it top to bottom. The base is the official Rust image, pinned to a minor version, so the compiler is the one in the tag and not whatever "latest" meant on the day of the build. slim drops compilers and tools you do not need, but it also drops pkg-config and the C library headers that many crates need at build time, so those are installed explicitly: libssl-dev for anything linking OpenSSL, gdb for debugging, git because the editor and cargo both want it. The package lists are deleted in the same RUN step so they do not stay in a layer. A non-root user is created, because a container running as root writes root-owned files into your project folder on Linux hosts, which is the most common complaint about dev containers. The last two lines set the cargo target directory outside the project and add the formatter and linter, which the slim image does not include. The reason for the target directory is covered below, after the bug it caused.
{
"name": "app",
"build": { "dockerfile": "Dockerfile" },
"remoteUser": "dev",
"mounts": [
"source=app-cargo-registry,target=/usr/local/cargo/registry,type=volume",
"source=app-target,target=/workspace-target,type=volume"
],
"runArgs": ["--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined"],
"customizations": {
"vscode": {
"extensions": ["rust-lang.rust-analyzer", "vadimcn.vscode-lldb", "tamasfe.even-better-toml"],
"settings": { "rust-analyzer.check.command": "clippy" }
}
},
"postCreateCommand": "cargo fetch"
}I checked that the file parses as JSON; the Dev Containers specification also allows comments in it, and the tooling reads the keys shown. remoteUser makes the editor's terminal and extensions run as dev. The two mounts create named Docker volumes for the cargo registry and the build output, which persist across rebuilds of the container. runArgs adds the capabilities a debugger needs (more on that below). customizations.vscode lists the extensions that are installed inside the container, so the language server runs next to the compiler it needs. And postCreateCommand fetches dependencies once after creation.
Build the image and look inside
The Dev Containers tooling builds from the Dockerfile; the equivalent by hand is one command:
docker build -t app-dev -f .devcontainer/Dockerfile .devcontainerThe build took about 18 seconds on this machine with the base image already pulled. The result is 1.05 GB, against 817 MB for the base image: the extra 230 MB is gdb, git, the OpenSSL headers and the two rustup components. That is a one-time cost per developer, not per build, and layers are shared between images, but it is worth knowing that "slim" is not small once you add a debugger. A first command inside confirms who you are and what you have:
docker run --rm app-dev bash -c 'id -un; id -u; rustc --version; cargo --version'dev
1000
rustc 1.99.1 (58cf8f9c3 2026-10-13)
cargo 1.99.1 (5f94df478 2026-08-27)The user is dev with uid 1000 and the toolchain is 1.99.1. Note that the tag 1.99-slim-bookworm floats across patch releases: it was 1.99.1 the day I pulled it, and a later pull could carry 1.99.2. If you need byte-for-byte reproducibility, pin the full version (rust:1.99.1-slim-bookworm) or the image digest, and bump it deliberately.
The mistake that cost a build: a fresh volume is owned by root
My first devcontainer.json sent the build output to a named volume, app-target, mounted at /workspace-target, so that thousands of build files would not cross a slow host file-sharing layer. The first Dockerfile did not create that directory. I ran the build as the non-root user and got:
error: failed to create directory `/workspace-target/debug`
Caused by:
Permission denied (os error 13)Listing the mount point showed why: drwxr-xr-x 2 root root. When Docker creates a named volume at a path that does not exist in the image, it creates it as root. If the path does exist in the image, Docker copies that directory's ownership and contents into the empty volume on first use, so the cure is to create the directory in the Dockerfile and give it to the user before the user is switched. That is the mkdir -p ... /workspace-target and the extended chown in the Dockerfile shown above. Do the same for any directory you mount a volume onto. The registry volume worked from the start for exactly this reason: /usr/local/cargo/registry existed in the image and was owned by dev after the chown.
A smaller lesson hides in the same failure: my first timing script piped cargo's output through grep real, so the failure produced no visible error and a believable "0.93 seconds". Whenever you time a command, look at its exit status and output once before trusting the number.
What the cache volumes buy
With the fix in place I scripted the sequence the volumes exist for. The project depends on serde_json, so the first build has real work to do: download and compile it. Each step ran in a throwaway container, docker run --rm, with the same two volumes:
RUN="docker run --rm -v $PWD:/workspace -w /workspace \
-v app-cargo-registry:/usr/local/cargo/registry \
-v app-target:/workspace-target app-dev"
$RUN cargo build # cold: volumes empty
$RUN cargo build # new container, volumes kept
touch src/main.rs
$RUN cargo build # one source file changedcold (empty volumes) 2.92 s
new container, volumes kept 0.27 s
one source file touched 0.36 sThe cold build, including the dependency download, took 2.92 seconds. Throw the container away, start another, and the build finished in 0.27 seconds, because the registry and the compiled dependencies were still in the volumes. The container was disposable; the cache was not. That is the design to aim for: containers you can delete without a second thought, and a small set of named volumes that hold the expensive state. This crate is tiny, so the absolute numbers are small; on a project with hundreds of crates the same ratio is the difference between a minute and ten. In the same session cargo test reported 2 passed; 0 failed, cargo fmt --check passed and cargo clippy printed nothing, so the formatter and linter installed in the image work for the non-root user.
I did not measure bind-mounted versus volume build directories on a large project, and the claim that keeping target off the host file share is faster is widely reported for Docker Desktop on macOS and Windows but is not something this guide timed. On a Linux host, where bind mounts are native, putting target in the project folder is fine and lets host tools see it.
File ownership on the bind mount
The source tree is a bind mount of the host folder, and anything the container writes there belongs to someone on the host. After the run above, the lockfile written through the mount was owned by uid 501 on the host, which is my macOS user, even though the process in the container ran as uid 1000. Docker Desktop for macOS translates ownership across its file-sharing layer, so it just works. On a Linux host there is no translation: a container user with uid 1000 writes files as uid 1000, and if your login is uid 1001 you will find files you cannot edit. The Dockerfile accepts a UID build argument for that, and the Dev Containers tooling has an updateRemoteUserUID option (on by default on Linux) that remaps the container user to match the host at start-up. I could not test the Linux case here, so treat it as the documented behaviour.
The toolchain: image tag or rust-toolchain.toml, not both
A project that already pins its compiler in rust-toolchain.toml meets the image's compiler as a second source of truth. To see what happens, I added a file requesting 1.99.0 and ran cargo --version in the container, whose installed version is 1.99.1:
info: syncing channel updates for 1.99.0-aarch64-unknown-linux-gnu
info: latest update on 2026-10-01 for version 1.99.0 (b940084d7 2026-09-28)
info: downloading 3 components
warn: the missing active toolchain `1.99.0-aarch64-unknown-linux-gnu` has been auto-installedrustup obeyed the project file and downloaded a second toolchain into the container's home directory, at run time, in the container's writable layer. It worked, and it is the wrong place for it: the download repeats in every fresh container, it is invisible in the image, and the editor may start before it finishes. Pick one source of truth. For a team that wants the container to be the environment, set the Dockerfile's base tag to the version in rust-toolchain.toml and keep them equal in review; or install the toolchain from the file during the build with RUN rustup show in the project directory so it is baked in. For a team that supports contributors who do not use the container, keep rust-toolchain.toml authoritative and let the image follow it.
Choosing what to install in the image
The package list in the Dockerfile is the part that grows. A useful test for each line is whether a build or a test fails without it. pkg-config and libssl-dev pass that test for any crate that links OpenSSL, and a missing one shows up as a build-script error naming the library, which is easy to trace back to a package. Developer conveniences such as a shell prompt, ripgrep or jq fail the test, and they belong either in a personal dotfiles setup that the editor can apply to every container, or in a second image layer that only developers use. Keeping the base image to what the build needs also means the CI image, which is the same file, stays small and its cache stays warm. When you do add a tool, add it with a pinned version where the tool allows, such as cargo install --locked cargo-deny --version 0.20.2, so the environment is reproducible and a new release of a helper does not change what your contributors see.
Prebuilt features are the alternative to writing a Dockerfile. The Dev Containers specification lets devcontainer.json list features such as a Rust toolchain or Git, which the tooling layers onto a base image. They are convenient for a quick start and give you less control over exactly what is installed. I used a Dockerfile because it is the more transparent choice and the one a CI job can build without the Dev Containers tooling, and I did not test the features mechanism.
Debugging inside a container
A debugger uses ptrace, and Docker's default seccomp profile and capability set have historically blocked it, which is why the example devcontainer.json adds --cap-add=SYS_PTRACE and seccomp=unconfined. I ran gdb in batch mode against the built binary twice, once with the defaults and once with those flags. In both runs the program ran under gdb and exited normally, with no ptrace error:
total = 0
[Inferior 1 (process 18) exited normally]
No stack.So on Docker Desktop for Mac the flags were not needed for gdb to launch a process. I would not conclude that they are never needed: that Docker runs containers inside a Linux virtual machine with its own defaults, and a Linux host with Docker Engine and the standard seccomp profile is the case where debuggers have been reported to fail. The program in that test also ran without arguments and no breakpoint was hit, which means this was a check that gdb can start a process, not a debugging session. Keep the flags if your team includes Linux hosts, and note that seccomp=unconfined weakens the container's isolation, which matters if you run untrusted code in it.
Using the same image in CI
A dev container pays for itself twice if CI runs the same image. The commands in the container are ordinary cargo, so a CI job needs no special tooling beyond running them in the image built from the same Dockerfile:
docker build -t app-dev -f .devcontainer/Dockerfile .devcontainer
docker run --rm -v "$PWD:/workspace" -w /workspace app-dev \
bash -c 'cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test'When a check fails in CI and passes locally, the usual cause is a different environment; here there is none to differ. The cost is image build time, which a registry cache or a prebuilt image removes. Most CI systems can also run a job directly in a container image, which skips the build step if you publish the image.
A day-to-day routine
- Open the folder in the container from the editor's command palette ("Reopen in Container"). The editor builds the image the first time and reuses it after. I did not run this step.
- Rebuild the container after any change to the Dockerfile or
devcontainer.json. Volumes survive a rebuild; only the image is replaced. - Reset the caches when a build behaves strangely:
docker volume rm app-cargo-registry app-target. The next build is cold, and nothing else is lost. - Add system packages in the Dockerfile, not by hand in a running container. A package installed by hand disappears at the next rebuild, which is a surprise for the person who added it.
- Keep secrets out of the image. Pass them at run time. The image is built from files in the repository and may be pushed to a registry.
Where dev containers are the wrong tool
- Heavy native work on macOS or Windows. File sharing across the virtual machine boundary is slower than a native disk, and the build directory in a volume only helps with compile output. If rust-analyzer feels slow on a large workspace, the cause may be the mount, and a native toolchain with the Dockerfile used only for CI is a valid choice.
- GUI and device work. Anything that needs a display, a USB programmer or a GPU needs extra configuration, and the embedded guides on this site flash hardware from the host for this reason.
- Cross-architecture surprises. The image here is aarch64 Linux because the host is an M1. A teammate on x86-64 pulls a different build of the same tag. Behaviour is usually identical; inline assembly and performance numbers are not.
- The editor part is unverified. The Dockerfile, the volumes, the user, the toolchain behaviour and the cargo commands were run. The extension installation, port forwarding and the editor's own settings were not, because the Dev Containers extension is not available on this machine.
- One container, one project. Several services (a database and the app) call for Docker Compose and a different
devcontainer.jsonshape, which this guide does not cover.
The measured facts are these: the image builds in about 18 seconds to 1.05 GB, a fresh volume mounted at a path the image does not contain is owned by root and breaks a non-root build until the Dockerfile creates and owns that path, a named volume turns a 2.92-second cold build into a 0.27-second one in a new container, and a conflicting rust-toolchain.toml silently downloads a second compiler at run time. Each of those is a line in a file you commit.


