Migrating from Docker Desktop to Nix DevShells

Updated

September 12, 2026

Migrating from Docker Desktop to Nix DevShells

docker run golang:1.24 go test is a VM, a daemon, and a bind mount to run a compiler. The boring default is: Nix devShell for go / gopls / linters; containers only for Postgres, Redis, and other long-running state.

Mental model

Docker Desktop for go test Nix nix develop --command go test
What runs Linux VM + dockerd + container Host go from /nix/store
Startup Seconds to tens of seconds Milliseconds after first substitute
Editor Dev Containers / remote Linux Local gopls on PATH
Files Virtio/osxfs bind mounts Native disk

Keep Docker/Podman for databases. Do not keep it for compilers. Same pkgs.go as buildGoModule on 26.05; GOTOOLCHAIN=local so Go does not download another SDK.

Worked examples

Case 1: Replace a compile container

Was:

docker run --rm -v "$PWD":/src -w /src golang:1.24 go test ./...

Save as flake.nix:

# flake.nix
{
  description = "Native desk Go environment";

  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";

  outputs = { self, nixpkgs }:
    let
      pkgs = nixpkgs.legacyPackages.x86_64-linux;
    in
    {
      devShells.x86_64-linux.default = pkgs.mkShell {
        packages = [ pkgs.go pkgs.gopls pkgs.golangci-lint pkgs.delve ];
        env.GOTOOLCHAIN = "local";
        env.CGO_ENABLED = "0";
      };
    };
}
nix develop --command go test ./...

Output (shape):

ok      desk/service    0.015s

Same tests, no VM.

Case 2: Time it once

time nix develop --command go version
time docker run --rm golang:1.24 go version

After Nix has substituted go, the first command should be far cheaper. Docker always pays daemon + create + start. Write the numbers in a comment in the flake so the next argument dies.

Case 3: Hybrid — Nix CLI, Podman Postgres

Save as start_db.sh:

# start_db.sh
#!/usr/bin/env bash
set -euo pipefail
podman rm -f desk-db >/dev/null 2>&1 || true
podman run -d --name desk-db \
  -p 5432:5432 \
  -e POSTGRES_PASSWORD=desk-dev \
  postgres:16-alpine

Save as the shell packages line:

# flake.nix fragment
{
  packages = [ pkgs.go pkgs.postgresql pkgs.podman ];
}
nix develop --command bash start_db.sh
nix develop --command psql postgres://postgres:desk-dev@127.0.0.1:5432/postgres -c 'SELECT 1'

The server is a container. The client (psql, go) is Nix. Password is a dev secret; production uses sops.

Case 4: Editor stays on the host

nix develop --command which gopls

VS Code / Zed / Neovim should see that path via direnv (use flake). Disable Dev Containers for this repo. If gopls is missing, add pkgs.gopls to the shell, not a golang image tag.

Case 5: When to keep Compose

Keep compose.yml for:

  • Postgres + Redis + localstack as a set
  • Matching production’s Linux userspace for a weird native addon

Do not keep golang / node / rust services in Compose if Nix already provides them.

# compose.yml — keep
services:
  db:
    image: postgres:16-alpine
    ports: ["5432:5432"]
# delete:
#  go:
#    image: golang:1.24

CI: nix develop --command go test (or buildGoModule doCheck), not docker compose run go.

The trap

The trap is moving the IDE into the container because “that is how we did Node.” You wanted PATH isolation. Nix already did that. Remote-container then fights file watchers and extensions.

The other trap is pinning golang:latest in CI and Nix go on the laptop. Same pin: 26.05 pkgs.go in both, or buildGoModule in CI and no golang image.

The boring rule

  • Compilers and LSPs: Nix devShell.
  • Stateful daemons: Podman/Docker.
  • Time go version both ways once; keep the faster one for the compiler.
  • GOTOOLCHAIN=local. Same go as buildGoModule.
  • Dev Compose passwords stay out of production flakes.

Try this

  1. Case 2 timings; write the numbers in a comment in the flake.
  2. which gopls after direnv allow.
  3. Start Postgres with Case 3; go test against localhost:5432 without a golang container.
  4. Delete the golang: service from Compose if it only ran tests.