The setup that finally worked
Getting a VS Code devcontainer to build a pgrx-based PostgreSQL extension turned out to be an exercise in understanding when container tools run, and as whom. Nobody sets up a devcontainer expecting PostgreSQL to refuse to start as root, or for files created during image build to disappear when the container actually starts. But that's exactly what happened with etcd_fdw, a PostgreSQL extension built on pgrx.
Three issues caused most of the pain: PostgreSQL's refusal to run as root, cargo tooling installed with the wrong file ownership, and pgrx initialization happening too early in the container lifecycle. Each had a straightforward fix.
PostgreSQL won't run as root
cargo pgrx test starts real PostgreSQL instances, and PostgreSQL refuses to run as the root user by design. A devcontainer that runs as remoteUser: "root" will fail as soon as integration tests try to launch PostgreSQL.
|
1 2 3 4 5 6 |
# Create a non-root user early in the image build RUN useradd -m -s /bin/bash -u 1000 vscode && \ echo "vscode ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers # Switch to that user for subsequent steps that create user-owned files USER vscode |
Runtime behavior matters as much as build steps when designing the container user. Create a non-root user early in the Dockerfile and use it for everything that follows.
Cargo ownership is an ordering problem
The official Rust images point cargo at global paths like /usr/local/cargo. If cargo-based tools such as cargo-pgrx get installed while the container is still running as root, those directories become root-owned. The non-root user who later tries to use them hits permission errors because the cargo cache and registry files simply aren't writable.
|
1 2 3 |
warning: failed to write cache, path: /usr/local/cargo/registry/index/.../.cache/pg/rx/pgrx, error: Permission denied error: failed to open `/usr/local/cargo/registry/cache/index.crates.io-.../ident_case-1.0.1.crate` Caused by: Permission denied |
The fix is a matter of sequence:
- Create the non-root user before installing anything user-specific.
- Point
CARGO_HOMEandPATHat a directory inside the user's home, such as/home/vscode/.cargo. - Install
cargo-pgrxas that non-root user so every cargo file lands in the user-writable home location.
|
1 2 3 4 5 6 7 8 |
USER root RUN useradd -m -s /bin/bash -u 1000 vscode && \ echo "vscode ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers USER vscode ENV PATH="/home/vscode/.cargo/bin:${PATH}" ENV CARGO_HOME="/home/vscode/.cargo" RUN cargo install --force --locked [email protected] |
With that arrangement, registry cache, downloaded binaries, and installed tools all belong to vscode and remain writable at runtime.
pgrx initialization must survive the container start
Running cargo pgrx init during the Docker build creates /home/vscode/.pgrx/config.toml in the image layer. The devcontainer then starts, and the error still appears during cargo build:
|
1 |
Error: /home/vscode/.pgrx/config.toml not found. Have you run `cargo pgrx init` yet? |
Bind mounts are the culprit. VS Code mounts the workspace or other host directories over whatever sits at the target path, including /home/vscode/.pgrx. If the corresponding host directory is empty, or on Windows the host path resolves differently than expected, the running container never sees the config that was baked into the image. Host directory contents effectively replace image-built files.
|
1 2 3 |
"mounts": [ "source=${localEnv:HOME}/.pgrx,target=/home/vscode/.pgrx,type=bind" ] |
This led to two changes. First, the cargo registry cache goes into a Docker volume rather than a bind mount, which behaves reliably across operating systems. Second, cargo pgrx init moved to postCreateCommand, so initialization runs after the container is live and the runtime user writes the config directly to their home directory. Adding --pg17 download to that command fetches prebuilt PostgreSQL 17 binaries instead of building PostgreSQL from source, which saves considerable setup time.
|
1 2 3 4 5 |
"mounts": [ "source=etcd-fdw-cargo-cache,target=/home/vscode/.cargo/registry,type=volume" ], "postCreateCommand": "cargo pgrx init --pg17 download && cargo build", "remoteUser": "vscode", |
Docker access for testcontainers
Test suites that use the Rust testcontainers library need an available Docker daemon inside the devcontainer. The practical approach avoids nested Docker entirely: reuse the host's Docker socket through the docker-outside-of-docker devcontainer feature.
|
1 2 3 |
"features": { "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {} } |
When using this feature, the container user still needs permission to reach the Docker socket, typically by being in the docker group. VS Code's feature wiring handles most of that setup automatically.
Assembling the working configuration
The condensed configuration below captures the patterns that worked. The full version lives in the etcd_fdw repository, and this excerpt is not intended as a drop-in for every project but as a reference for the important decisions.
Dockerfile essentials
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
FROM rust:1-bookworm RUN apt-get update && apt-get install -y \ build-essential bison flex clang protobuf-compiler \ libreadline8 libreadline-dev git curl pkg-config libssl-dev sudo && \ rm -rf /var/lib/apt/lists/* # Create non-root user RUN useradd -m -s /bin/bash -u 1000 vscode && \ echo "vscode ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers USER vscode ENV PATH="/home/vscode/.cargo/bin:${PATH}" ENV CARGO_HOME="/home/vscode/.cargo" # Install pgrx for the runtime user RUN cargo install --force --locked [email protected] WORKDIR /workspace CMD ["sleep","infinity"] |
devcontainer.json essentials
|
1 2 3 4 5 6 7 8 |
{ "name": "pgrx Development", "build": { "dockerfile": "Dockerfile", "context": "." }, "mounts": [ "source=etcd-fdw-cargo-cache,target=/home/vscode/.cargo/registry,type=volume" ], "postCreateCommand": "cargo pgrx init --pg17 download && cargo build", "remoteUser": "vscode", "features": { "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {} } } |
--pg17 downloadmakespgrxpull a prebuilt PostgreSQL 17 rather than compiling it, which keeps setup fast.- The cargo registry cache lives in a Docker volume specifically for cross-platform reliability, avoiding bind mount path issues.
Troubleshooting checklist
When something fails, work through these in order:
- Confirm the container runs as a non-root user;
remoteUsershould not be root. - Check that
~/.pgrx/config.tomlexists inside the running container, not just in the image. - Verify the cargo cache is owned by the runtime user at
/home/vscode/.cargo. - For bind mounts originating from Windows, ensure the host path resolves to the expected Linux path.
The takeaway
Reproducible development depends on matching build-time setup with runtime expectations. pgrx starts PostgreSQL at test time, so the container user must be a non-root user from the start. User-specific tools need correct ownership from the moment they are installed. And initialization that writes state needs to happen after the container starts, not during image creation. Those three alignment points produce a development environment that is pleasant, reproducible, and free of the grey hairs.



