I like reproducible development. I also like short feedback loops. Combining both for pgrx was… educational. 🙂 In this post, I share the mistakes, the small pains, and the fixes I used to get a working VS Code dev container for a Rust project that builds PostgreSQL extensions with pgrx. If you’re writing extensions or using pgrx in a team, this will save you a few grey hairs.
Table of Contents
TL;DR:
root. Don’t run your devcontainer as root if cargo pgrx test must work.cargo pgrx init after the container starts (use postCreateCommand) so the config persists.I was setting up a devcontainer for an etcd_fdw project that uses pgrx to produce a PostgreSQL extension. At first, it seemed straightforward: start from the official Rust image, install build deps, add cargo-pgrx, and be done.
But real life is noisy. I hit permission errors, failing tests, and an annoying config.toml not found. Have you run 'cargo pgrx init' yet?. After some digging, I discovered three core issues that you should watch out for.
PostgreSQL will not run as root by design. cargo pgrx test launches PostgreSQL instances for integration testing. If your devcontainer runs as root (or if you initialize pgrx as root and then switch user incorrectly), tests will fail.
First, I tried to set remoteUser: "root" in devcontainer.json and installing everything as root. The build failed with errors when tests attempted to run PostgreSQL.
The fix is (simplified Dockerfile snippet):
|
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 |
The key takeaway is to think about runtime actions (what cargo pgrx test will do) when you design the container user. Create and use a non-root user early.
This one is subtle. The official Rust images set global cargo paths that default to /usr/local/cargo. If you install cargo-based tools (like cargo-pgrx) while still root, you may populate root-owned directories. Later, the non-root user cannot write to those locations and will get permission errors like:
|
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 mistake is ordering. Installing tools as root and then switching to a non-root user leaves root-owned cache and registry files behind.
Correct approach would be:
CARGO_HOME and adjust PATH to the user's homecargo-pgrx as that non-root user so all cargo files live under /home/vscode/.cargoExample:
|
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 cargo-pgrx@0.16.1 |
Now downloads, registry cache, and installed binaries belong to vscode and are writable by the runtime user.
After fixing users and permissions, I still hit this error during cargo build:
|
1 |
Error: /home/vscode/.pgrx/config.toml not found. Have you run `cargo pgrx init` yet? |
I originally ran cargo pgrx init during the Docker build (image creation). That created /home/vscode/.pgrx/config.toml in the image. But when you start a devcontainer in VS Code, mounts or overlays (bind mounts, workspace folders, or explicit mounts) can hide that file. On Windows, bind mount path resolution is especially fragile. The result is that the running container doesn’t see the config produced at image build time.
|
1 2 3 |
"mounts": [ "source=${localEnv:HOME}/.pgrx,target=/home/vscode/.pgrx,type=bind" ] |
The initial attempt failed because a bind mount will replace whatever is at /home/vscode/.pgrx with the host directory contents. If the host path is empty or different (Windows path translation!), you lose the image-built config.
The final approach that worked is to use a Docker volume for cargo registry cache so we keep speed benefits and run cargo pgrx init in postCreateCommand, so initialization happens when the container is live and the runtime user can write to their home directory.
Optionally use --pg17 download to fetch prebuilt PostgreSQL binaries for pgrx instead of building PostgreSQL from source.
Example devcontainer.json bits:
|
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", |
Key takeaway is that some tooling must be initialized at runtime, not at image build time.
If your test suite uses testcontainers (Rust library) to spin up Docker containers for integration tests, you’ll need Docker access from inside the devcontainer. Rather than running Docker-in-Docker, I prefer to reuse the host Docker socket via the docker-outside-of-docker devcontainer feature. This keeps things simple on the host and avoids nested Docker complexity.
Example feature entry in devcontainer.json:
|
1 2 3 |
"features": { "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {} } |
Also make sure your container user has permission to access the Docker socket when needed. On Linux, that often means adding the user to the docker group. With the feature, VS Code handles most of that wiring.
Below is the condensed configuration we used. For the full version, check etcd_fdw repository. This is not a drop-in for every project, but it captures the important patterns.
|
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 cargo-pgrx@0.16.1 WORKDIR /workspace CMD ["sleep","infinity"] |
devcontainer.json (important parts):|
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": {} } } |
Notes:
--pg17 download instructs pgrx to fetch a prebuilt PostgreSQL 17. It saves time when you don’t need to build PG from source.If something breaks, check these in order:
remoteUser should be a regular user.~/.pgrx/config.toml exist inside the running container (not necessarily in the image)?/home/vscode/.cargo)?pgrx starts PostgreSQL at runtime. Design accordingly.postCreateCommand.Setting up devcontainers is a small engineering puzzle. For pgrx, the puzzle pieces are: PostgreSQL security, cargo ownership, and initialization timing. Align those and you get a pleasant, reproducible developer experience. 💙💛
