Skip to content

Commit f4167de

Browse files
committed
feat(persist-nix-var): add per-project /nix/var persistence feature
Without persisting /nix/var, Nix loses its SQLite database on every rebuild and re-downloads store paths that are already present in the shared /nix/store volume. Per-project volume avoids SQLite contention when multiple devcontainers share the store simultaneously.
1 parent 08fdfb5 commit f4167de

7 files changed

Lines changed: 166 additions & 9 deletions

File tree

src/persist-nix-store/README.md

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Persist Nix store (persist-nix-store)
22

3-
Mounts a named Docker volume at `/nix/store` so the Nix package store survives container rebuilds
4-
without re-downloading packages.
3+
Mounts a shared named Docker volume at `/nix/store` so the Nix package store survives container
4+
rebuilds without re-downloading packages.
55

66
## Example Usage
77

@@ -11,27 +11,33 @@ without re-downloading packages.
1111
}
1212
```
1313

14-
Combine with the [chezmoi](../chezmoi/) feature and `defer_scripts: true` to skip Nix downloads on
15-
every rebuild:
14+
Combine with [persist-nix-var](../persist-nix-var/) so Nix also retains its database across
15+
rebuilds and recognises already-present store paths. Add the [chezmoi](../chezmoi/) feature with
16+
`defer_scripts: true` to run Nix installation at post-create time (when the volumes are mounted):
1617

1718
```json
1819
"features": {
1920
"ghcr.io/ckagerer/devcontainer-features/chezmoi:1": {
2021
"dotfiles_repo": "your-user/dotfiles",
2122
"defer_scripts": true
2223
},
23-
"ghcr.io/ckagerer/devcontainer-features/persist-nix-store:1": {}
24+
"ghcr.io/ckagerer/devcontainer-features/persist-nix-store:1": {},
25+
"ghcr.io/ckagerer/devcontainer-features/persist-nix-var:1": {}
2426
}
2527
```
2628

2729
## How It Works
2830

2931
The volume is mounted at `/nix/store` — not at `/nix`. This is intentional:
3032

31-
- `/nix/store` is content-addressable and designed for concurrent read access. Multiple devcontainers
32-
can share the same volume safely, even when open simultaneously.
33-
- `/nix/var` (the Nix SQLite database, profiles, and GC roots) stays inside each container's own
34-
writable layer. Sharing it would cause database corruption under concurrent writes.
33+
- `/nix/store` is content-addressable and safe for concurrent reads. Multiple devcontainers
34+
can share the same volume simultaneously without conflicts.
35+
- `/nix/var` (the Nix SQLite database, profiles, and GC roots) is handled separately by
36+
[persist-nix-var](../persist-nix-var/), which uses a per-project volume to avoid SQLite
37+
write contention between concurrent containers.
38+
39+
Without `persist-nix-var`, Nix loses its database on every rebuild and re-downloads packages
40+
that are already present in the store.
3541

3642
During build, `install.sh` pre-creates `/nix/store` with the remote user's ownership. Docker seeds
3743
an empty named volume from the image layer on first mount, so the directory is writable by the Nix

src/persist-nix-var/README.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Persist Nix var (persist-nix-var)
2+
3+
Mounts a per-project named Docker volume at `/nix/var` so the Nix SQLite database,
4+
user profiles, and GC roots survive container rebuilds.
5+
6+
Without this feature, Nix loses its database on every rebuild and re-downloads packages
7+
that are already present in `/nix/store` — even when used together with
8+
[persist-nix-store](../persist-nix-store/).
9+
10+
## Example Usage
11+
12+
```json
13+
"features": {
14+
"ghcr.io/ckagerer/devcontainer-features/persist-nix-store:1": {},
15+
"ghcr.io/ckagerer/devcontainer-features/persist-nix-var:1": {}
16+
}
17+
```
18+
19+
Combine with the [chezmoi](../chezmoi/) feature and `defer_scripts: true` to fully skip
20+
Nix downloads on rebuilds:
21+
22+
```json
23+
"features": {
24+
"ghcr.io/ckagerer/devcontainer-features/chezmoi:1": {
25+
"dotfiles_repo": "your-user/dotfiles",
26+
"defer_scripts": true
27+
},
28+
"ghcr.io/ckagerer/devcontainer-features/persist-nix-store:1": {},
29+
"ghcr.io/ckagerer/devcontainer-features/persist-nix-var:1": {}
30+
}
31+
```
32+
33+
## How It Works
34+
35+
The volume is mounted at `/nix/var` — not at `/nix`. This is intentional:
36+
37+
- `/nix/var` contains the Nix SQLite database (`db/`), per-user profiles (`profiles/`),
38+
and GC roots (`gcroots/`). Persisting it ensures Nix recognises store paths that are
39+
already present in `/nix/store` and skips redundant downloads.
40+
- The volume is **per-project** (named with `${localWorkspaceFolderBasename}` and
41+
`${devcontainerId}`), so concurrent devcontainers each have their own isolated database.
42+
This avoids SQLite write contention and profile conflicts that would occur with a shared
43+
`/nix/var`.
44+
45+
## Volume Name
46+
47+
The volume is named `devcontainer-<project>-persist-nix-var-<id>` and is scoped to each
48+
devcontainer. Combined with the shared `devcontainer-nix-store` volume from
49+
[persist-nix-store](../persist-nix-store/), packages are downloaded once and registered
50+
once per project — surviving all subsequent rebuilds.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "persist-nix-var",
3+
"id": "persist-nix-var",
4+
"version": "1.0.0",
5+
"description": "Mounts a per-project named volume at /nix/var to persist the Nix database, profiles, and GC roots across devcontainer rebuilds. Use together with persist-nix-store so the Nix daemon recognises already-downloaded store paths and skips redundant downloads.",
6+
"documentationURL": "https://github.com/ckagerer/devcontainer-features/tree/main/src/persist-nix-var",
7+
"options": {},
8+
"installsAfter": [
9+
"ghcr.io/devcontainers/features/common-utils",
10+
"ghcr.io/ckagerer/devcontainer-features/persist-nix-store"
11+
],
12+
"mounts": [
13+
{
14+
"source": "devcontainer-${localWorkspaceFolderBasename}-persist-nix-var-${devcontainerId}",
15+
"target": "/nix/var",
16+
"type": "volume"
17+
}
18+
]
19+
}

src/persist-nix-var/install.sh

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#!/usr/bin/env bash
2+
# (C) Copyright 2026 Christian Kagerer
3+
# Purpose: Pre-create /nix/var with correct ownership so Docker seeds the
4+
# per-project named volume from the image layer on first mount.
5+
#
6+
# The volume is mounted at /nix/var (not /nix) so each project keeps its own
7+
# Nix SQLite database, profiles, and GC roots — avoiding lock contention when
8+
# multiple devcontainers share the same /nix/store volume simultaneously.
9+
10+
set -o errexit -o nounset -o pipefail
11+
12+
mkdir -p /nix/var
13+
chown "${_REMOTE_USER}:$(id -gn "${_REMOTE_USER}")" /nix /nix/var
14+
chmod 755 /nix /nix/var
15+
16+
echo "Done"
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#!/bin/bash
2+
# Tests that /nix/var is owned and writable by the non-root remote user.
3+
4+
set -e
5+
6+
# shellcheck source=/dev/null
7+
source dev-container-features-test-lib
8+
9+
check "/nix/var directory exists" test -d /nix/var
10+
check "/nix/var is writable by the current user" test -w /nix/var
11+
# shellcheck disable=SC2016
12+
check "/nix/var permissions are 755" sh -c '[ "$(stat -c "%a" /nix/var)" = "755" ]'
13+
# shellcheck disable=SC2016
14+
check "/nix/var is owned by the current user" sh -c '[ "$(stat -c "%U" /nix/var)" = "$(id -un)" ]'
15+
# shellcheck disable=SC2016
16+
check "/nix is owned by the current user" sh -c '[ "$(stat -c "%U" /nix)" = "$(id -un)" ]'
17+
18+
reportResults
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"non_root_user": {
3+
"image": "ubuntu:jammy",
4+
"features": {
5+
"ghcr.io/devcontainers/features/common-utils:1": {
6+
"installZsh": false,
7+
"installOhMyZsh": false,
8+
"upgradePackages": false,
9+
"username": "octocat"
10+
},
11+
"persist-nix-store": {},
12+
"persist-nix-var": {}
13+
},
14+
"remoteUser": "octocat"
15+
}
16+
}

test/persist-nix-var/test.sh

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#!/bin/bash
2+
3+
# This test file will be executed against an auto-generated devcontainer.json that
4+
# includes the 'persist-nix-var' Feature with no options.
5+
#
6+
# For more information, see: https://github.com/devcontainers/cli/blob/main/docs/features/test.md
7+
#
8+
# These scripts are run as 'root' by default. Although that can be changed
9+
# with the '--remote-user' flag.
10+
#
11+
# This test can be run with the following command:
12+
#
13+
# devcontainer features test \
14+
# --features persist-nix-var \
15+
# --remote-user root \
16+
# --skip-scenarios \
17+
# --base-image mcr.microsoft.com/devcontainers/base:ubuntu \
18+
# /path/to/this/repo
19+
20+
set -e
21+
22+
# shellcheck source=/dev/null
23+
source dev-container-features-test-lib
24+
25+
check "/nix/var directory exists" test -d /nix/var
26+
check "/nix/var is writable" test -w /nix/var
27+
# shellcheck disable=SC2016
28+
check "/nix/var permissions are 755" sh -c '[ "$(stat -c "%a" /nix/var)" = "755" ]'
29+
# shellcheck disable=SC2016
30+
check "/nix permissions are 755" sh -c '[ "$(stat -c "%a" /nix)" = "755" ]'
31+
32+
reportResults

0 commit comments

Comments
 (0)