+++ title = "Contributing" +++
Contributions are welcome! Here are some ways to help:
- Fork and clone the repository
- Enter the dev shell:
nix develop(or use direnv for automatic activation) - The dev shell provides all required tools (bats, lefthook, task, convco, typos, etc.)
- Git hooks are managed by lefthook: they run automatically on commit (formatting, flake check, tests, commit message linting)
- Make your changes
- Submit a pull request
- Source: github.com/Dauliac/nix-oci
- Issues: github.com/Dauliac/nix-oci/issues
nix-oci is a flake-parts module. All build-time logic (container definitions, library functions, checks, packages) is structured as flake-parts modules composed through perSystem. Consumers import the module via imports = [ inputs.nix-oci.modules.flake.nix-oci ];.
Modules are auto-discovered using import-tree instead of manual import lists. Each .nix file under nix/modules/ is automatically imported; no need to register new files anywhere.
Convention: directories prefixed with _ (e.g. _options/, _nixos/, _nixos-oci/) are excluded from import-tree auto-discovery and must be imported explicitly where needed. Use this for internal submodules, shared option fragments, or modules evaluated in a separate NixOS context.
Public library functions are declared through nix-lib under the nix-lib.lib.oci.* namespace. Each function in nix/modules/oci/lib/ declares its type, description, and implementation. Consumers access them via config.lib.oci.* in perSystem context.
This gives us typed function signatures, auto-generated reference docs, and testable library APIs.
Core logic lives in pure Nix files under nix/lib/:
oci.nix: core OCI functions (ports, layers, labels, shadow, root, sandbox, seccomp)container-checks.nix: integration check helpersidentity.nix: passwd/group file parsing for fromImage base imagesdeploy.nix: deploy helpers (copyScript, autoStart, run args)eval-container.nix: shared NixOS container evaluation function
nix-lib wrappers in nix/modules/oci/lib/ delegate to these pure functions. Deploy modules import them directly (not through nix-lib config).
A single evalContainerNixos function (nix/lib/eval-container.nix) evaluates container NixOS configs. Both flake-parts build-time modules and deploy modules delegate to it, ensuring consistent behavior.
Per-container options (package, entrypoint, healthcheck, hardening, etc.) are defined once in nix/modules/oci/containers/_options/ and reused by both flake-parts and deploy module paths.
Multiple modules can contribute container options via oci.perContainer, a deferred module collector. This lets option definitions (hardening, performance, healthcheck, etc.) live in separate files while composing into a single per-container submodule.
The three deploy targets (NixOS, home-manager, system-manager) are symmetric by design. They share the same oci.containers option tree, the same image build pipeline, and the same _module.args injection (nix2container, nixLibNixosModule). The compose module (nix/modules/deploy/nix-oci/compose.nix) wires them identically:
# Each target gets the same sub-modules and the same injected args
flake.modules.nixos.nix-oci = { imports = [ enable backend containers load run ]; ... };
flake.modules.homeManager.nix-oci = { imports = [ enable backend containers load run ]; ... };
flake.modules.systemManager.nix-oci = { imports = [ enable backend containers load run ]; ... };When adding a new feature or option to one deploy target, add it to all three (or to the shared modules in deploy/nix-oci/options/). Asymmetry between targets is a bug.
The oci.container.* namespace inside the NixOS container eval is the single communication bus between all module layers. Every piece of data flows through it:
flowchart TB
FP["flake-parts<br/>oci.containers.‹name›.*"]
DP["deploy (NixOS / HM / SM)<br/>oci.containers.‹name›.*"]
SO["Shared options<br/>_options/"]
subgraph "NixOS container eval"
BUS["oci.container.* bus"]
NIXOS["nixos-oci modules<br/>+ service adapters"]
HM["home-manager-oci"]
OUT["oci.container._output.*"]
end
IMG["nix2container.buildImage"]
SO --> FP & DP
FP & DP -->|"forward into"| BUS
BUS --> NIXOS & HM
NIXOS & HM --> OUT
OUT -->|"consumed by"| IMG
Key rule: flake-parts and deploy container options MUST NOT directly produce OCI image config. They forward values into the oci.container.* namespace of the NixOS eval, which derives all OCI output fields (entrypoint, healthcheck, env, labels, rootFilesystem, etc.) through the _output.* interface. The image builder then reads _output.* to produce the final image.
This ensures:
- A single derivation path for every OCI field, regardless of whether the image is built by flake-parts or a deploy module
- Service adapters can enrich/override any field (e.g. inject a health endpoint) without the caller knowing
- New OCI fields only need to be added in one place (a
_nixos-ocimodule)
nix-oci never uses IFD. All evaluation is pure: no derivation is built during nix eval, nix flake show, or nix flake check.
Why this matters:
- Eval stays fast: without IFD, evaluation is instant attribute traversal. IFD forces the evaluator to block while a derivation builds, creating sequential bottlenecks (eval → build → eval → build).
- No builder required for eval: tools like
nix flake show,nix flake check, and documentation generators work without a running Nix daemon or build sandbox. CI jobs that only need eval remain lightweight. - Hydra-friendly: Hydra separates evaluation from building. IFD collapses this boundary and breaks eval caching.
- Cross-compilation works: IFD derivations build for the build platform, which can cause confusing failures when cross-compiling for a different architecture.
- Smaller attack surface: IFD runs arbitrary derivations during eval, which expands trust requirements. Some Nix setups disable IFD entirely (
--no-allow-import-from-derivation).
Where this constraint shows up concretely: the fromImage identity merge reads base image /etc/passwd and /etc/group from committed source files via builtins.readFile instead of extracting them from the base image at eval time. This requires a manual lock step (extract → commit) but preserves eval purity.
Rule: if you need data from an external source (base image, generated file), commit it to the repo and read it with builtins.readFile or builtins.pathExists. Never use import (pkgs.runCommand ...) or similar IFD patterns.
NixOS service adapters (nix/modules/_nixos-oci/service-adapters/) automatically detect running services (nginx, postgresql, redis, etc.) and inject foreground mode, health endpoints, stop signals, and working directories into the oci.container bus. Users get production-ready container configs without manual OCI plumbing.
Use NixOS/module assertions to reject invalid configurations at eval time rather than producing broken images at build time. If a state combination is impossible or nonsensical, add an assertion, don't silently ignore it.
Examples already in the codebase:
_home-manager-oci/defaults.nix: assertshome.username == oci.container.user(prevents HM user mismatch)container-checks.nix: asserts mainService entrypoint was resolved, package is not null when required, etc.
Guidelines:
- If two options are mutually exclusive, assert it (e.g.
mainServicevs explicitpackagewith entrypoint) - If a combination leads to a broken image (empty entrypoint, missing user), assert it
- Prefer
assertor moduleassertionsover silent fallbacks: fail loud, fail early - Write a clear
messagethat tells the user what's wrong and how to fix it
nix/modules/oci/: flake-parts build-time modulesnix/modules/oci/lib/: nix-lib function declarations (typed, documented)nix/modules/oci/containers/_options/: shared per-container option definitionsnix/modules/deploy/: NixOS and Home Manager deploy modulesnix/modules/_nixos-oci/: NixOS container eval modules (service adapters, entrypoint, hardening)nix/modules/_home-manager-oci/: home-manager container eval defaults and assertionsnix/lib/: pure shared libraries (no module system dependency)examples/: usage examples (build, deploy-nixos, deploy-home-manager)nix/tests/: end-to-end and integration testsdocs/: documentation source (built with NDG)
# End-to-end tests (preferred way, via Taskfile)
task test
# All nix checks
nix flake check
# Integration test (NixOS VM)
nix build .#checks.x86_64-linux.deploy-integrationLefthook runs automatically on commit:
- pre-commit:
nix fmt,nix flake show,nix flake check,task test - commit-msg: convco (conventional commits) + typos check
Every option declaration lives in its own file, with the file path mirroring the option namespace path. This is the core architectural pattern of nix-oci.
# Option: oci.container.hardening.seccomp
# File: _nixos-oci/hardening/seccomp.nix
# Option: oci.cve.trivy.ignore.rootPath
# File: security/cve/trivy/ignore/root-path.nix
# Option: performance.runtime.memory (deploy submodule)
# File: _performance/memory.nix
-
Create one
.nixfile declaring the single option at its full dotted path:# nix/modules/oci/containers/_options/my-feature.nix { lib, ... }: { options.myFeature = lib.mkOption { type = lib.types.bool; default = false; description = "Enable my feature."; }; }
-
No registration needed —
perContainer.nixauto-discovers files in_options/viadiscoverModules(recursivebuiltins.readDir). For flake-level options,import-treeauto-discovers them. -
For submodule options (e.g.
performance.runtime.*), add the file to the_performance/directory. Thedefault.nixauto-discovers it viabuiltins.readDir. -
For
_nixos-oci/options, follow the split pattern:- Pure option declarations →
feature/option-name.nix - Computed outputs →
feature/outputs.nix - Assertions + config →
feature/config.nix
- Pure option declarations →
- Submodule inner options (
types.submodule { options = { ... }; }): keep together in one file (e.g.seccomp.nixdeclares the whole seccomp submodule with enable/profile/mode/customProfileJson) - OCI struct fields (healthcheck command/interval/timeout/retries): keep together — they map 1:1 to a single OCI config struct
- Computed outputs that aggregate multiple options: keep in
outputs.nix - Type-coupled structural files (perContainer, perTag, perArch): can't split
| Context | Mechanism | Skip prefix |
|---|---|---|
| Flake-parts top-level | import-tree ./modules |
_ |
| Per-container options | discoverModules ./_options (builtins.readDir) |
_ |
| Deploy submodule | _performance/default.nix (builtins.readDir) |
default.nix |
_nixos-oci/ modules |
import-tree ../../../_nixos-oci |
_ |
- Use kebab-case for filenames:
glibc-tunables-preset.nix, notglibcTunablesPreset.nix - Match the last segment of the option path:
oci.container.hardening.disableDns→disable-dns.nix - Submodules get a single file named after the submodule:
seccomp.nix,apparmor.nix - Subdirectories mirror namespace nesting:
cve/trivy/ignore/root-path.nix
- Format with
nix fmt - Prefix internal directories with
_(excluded from import-tree)
Never use import to pull in another module file. All module composition goes through auto-discovery (import-tree, discoverModules, builtins.readDir) or explicit module system imports lists. Raw import ./foo.nix bypasses the module system, creates invisible coupling, and breaks the one-file-per-option contract.
| Allowed | Forbidden |
|---|---|
imports = [ ./foo.nix ]; (module system) |
import ./foo.nix { inherit lib; } |
Auto-discovery via import-tree / discoverModules |
let x = import ./helpers.nix; |
builtins.readFile for data files (JSON, text) |
import (pkgs.runCommand ...) (IFD) |
Pure library imports in nix/lib/*.nix (shared libs) |
Importing a module file as a plain function |
Exceptions (the only places raw import is acceptable):
nix/lib/*.nixpure library files — these are not modules; they are plain functions imported by modules or nix-lib wrappers. Example:import ./oci.nix { inherit lib; }ineval-container.nix.import-treeitself — the root entry point that bootstraps auto-discovery.builtins.readFile— reading committed data files (passwd, JSON configs) is fine.
Why this matters:
- Auto-discovery means dropping a file makes it exist — no import list to forget
- Module-system
importsget merged, deduplicated, and ordered correctly - Raw
importevaluates immediately and skips merge/override/priority logic - Debugging circular imports is trivial when all composition goes through the module system
- New service adapters: add foreground mode, healthcheck, and stop signal support for more NixOS services (see
nix/modules/_nixos-oci/service-adapters/for examples). Any service underservices.*that can run in a container is a good candidate. - Tests for existing adapters: improve coverage of service adapter behavior (healthcheck injection, stop signal detection, entrypoint extraction) with Container Structure Tests or VM integration tests.
End-to-end integration tests running inside Debian-based NixOS VMs to validate the full deploy lifecycle for every target:
- NixOS deploy: VM that builds, loads, and runs OCI containers via the
nix-ociNixOS module, asserting service health, container metadata, and runtime behavior. - system-manager deploy: VM that provisions a non-NixOS (Debian) system with system-manager, deploys containers through
nix-oci's system-manager module, and validates that services start, healthchecks pass, and volumes are mounted correctly. - home-manager deploy: VM that runs home-manager as a regular user inside a Debian guest, deploys containers via the
nix-ocihome-manager module, and asserts per-user container lifecycle (start, healthcheck, stop). - Compose integration: VM tests that exercise the full
docker-compose/podman-composeworkflow generated by the compose module, covering multi-container stacks with networking, volume mounts, and dependency ordering.
These tests ensure the three deploy targets (NixOS, home-manager, system-manager) stay symmetric and catch regressions in the shared option tree, image loading, and service wiring.
VM-based tests that validate cross-compilation and multi-arch image builds:
aarch64-linuxcross-build: buildx86_64-linux→aarch64-linuximages usingpkgsCross, load them in a QEMUaarch64VM, and assert the container runs correctly (entrypoint, healthcheck, architecture label).aarch64-linuxemulated build: buildaarch64-linuximages viabinfmt_miscemulation, load them in a QEMUaarch64VM, and validate identical behavior to cross-built images.- Multi-arch manifest: build a multi-arch OCI index (
x86_64-linux+aarch64-linux), push to a local registry, pull from both architecture VMs, and assert each receives the correct platform-specific image. riscv64-linuxcross-build (stretch goal): cross-compile a minimal image forriscv64and validate the image manifest metadata without a full VM (no QEMUriscv64VM available in NixOS tests yet).
These tests guard against cross-compilation regressions (wrong pkgsCross wiring, architecture label mismatches, missing binfmt registration) and ensure multi-arch manifests are correctly assembled.