local-built-docker-service-with-nix-pinned-config-merge¶
Modules
A NixOS module for running a self-hosted app from a Docker image built on the
host (no registry, no dockerTools), while keeping the security-relevant
slice of the app's runtime JSON config reproducible in Nix — jq-merged into the
operator's hand-edited config on every start.
The problem¶
You have an app that:
- Ships as a Dockerfile in a source tree you sync onto the host yourself (rsync, a deploy step, a checkout) — you do not want to publish an image to a registry just to run it.
- Keeps its runtime config as a mutable JSON file that the operator edits by hand (auth tokens, model choices, agent definitions, sessions).
- Has a subset of that config that is security-relevant and must stay under version control — e.g. a capability / skills / tool allowlist that decides what the app is permitted to do.
You want (2) and (3) to coexist: operator edits survive redeploys, but the pinned surface is re-asserted from Nix and can never silently drift.
The insight: split ownership via a jq merge¶
The runtime config file has two owners:
- The operator owns the file as a whole — their hand-edits persist.
- Nix owns only the keys in the
pinnedConfigoption.
On every service start, an ExecStartPre step renders pinnedConfig to JSON in
the store and jq-merges it into the live config file, replacing only the
pinned keys and leaving everything else intact. The security-relevant surface is
therefore reproducible and version-controlled, while volatile operator state is
never clobbered.
The default mergeFilter (. * $pinned[0]) is a recursive deep merge: pinned
keys win, sibling operator keys survive. Override it for surgical control — e.g.
to authoritatively replace a whole subtree instead of deep-merging it:
mergeFilter = ''
.skills = ((.skills // {}) + { allowBundled: $pinned[0].allowBundled,
entries: $pinned[0].entries })
'';
Traps (the reason this module exists)¶
- Tag-bump-to-rebuild. The one-shot builder skips when
imageTagalready exists, so redeploys are cheap — but a code change alone rebuilds nothing. You must bumpimageTagto force a rebuild. This is deliberate (cheap redeploys) but bites the unaware. - No Dockerfile ⇒ hard fail. The build unit refuses to start if
sourceDirhas noDockerfile, so a forgotten source sync fails loudly instead of running a stale image. - uid/gid must match the image. State and workspace dirs are chowned to
runUid/runGid(default1000) so the in-container user can read/write them. If the image bakes a different uid, container writes fail withEACCES— inspect the image and set these knobs accordingly. - Host networking is a footgun, so it's fenced. The default
dockerArgsuse--network=host(handy when the app must reach a loopback-only backend) and then dropNET_RAW/NET_ADMINand setno-new-privileges:trueto claw back part of what host networking gives away. If you don't need host networking, replacedockerArgswith a-ppublish and a bridge network. - Loopback binding = no external reach. If your app binds loopback (a common safe default), it's only reachable locally; front it with a tunnel/reverse-proxy for external access. Nothing in this module exposes it.
- The pinned merge runs every start, including after crashes. Because it's
an
ExecStartPreon aRestart=alwaysservice, a restart loop re-asserts the pinned surface each time. That's the point, but keep the merge idempotent (the default deep-merge is).
Usage¶
{
imports = [ ./local-built-docker-service-with-nix-pinned-config-merge ];
services.localDockerApp = {
enable = true;
name = "myapp";
# Source tree with a Dockerfile, synced onto the host out-of-band.
sourceDir = "/srv/myapp/src";
# Bump this to force a rebuild.
imageTag = "myapp:local-1.0.0";
# Command run inside the container (appended after the image tag).
command = [
"node" "dist/index.js" "gateway"
"--bind" "loopback"
"--port" "18789"
];
# docker run flags: mount state/workspace, set env, pick the network.
dockerArgs = [
"--network=host"
"--cap-drop=NET_RAW"
"--cap-drop=NET_ADMIN"
"--security-opt=no-new-privileges:true"
"--init"
"-e" "HOME=/home/node"
"-v" "/var/lib/myapp/state:/home/node/.myapp"
"-v" "/var/lib/myapp/workspace:/home/node/.myapp/workspace"
];
# The Nix-owned, version-controlled config surface (e.g. a capability
# allowlist). Merged into the runtime config file on every start.
pinnedConfig = {
allowList = [ "read-files" "run-shell" "http-fetch" ];
entries."http-fetch" = {
enabled = true;
env.BASE_URL = "https://api.example.com/v1";
};
};
# Optional secrets, passed via --env-file (point at a decrypted path).
envFile = "/run/secrets/myapp.env";
};
}
Key options¶
| Option | Purpose |
|---|---|
name |
Short identifier (default localapp) used to name the units, the /var/lib/<name> tree and the rendered store files. Change it to run more than one instance. |
sourceDir |
Docker build context on the host (must contain a Dockerfile). |
imageTag |
Tag of the locally built image. Bump to rebuild. |
command |
Command + args run inside the container. |
dockerArgs |
Extra docker run flags (volumes, env, network, security). |
pinnedConfig |
Attrset Nix owns; rendered to JSON and merged in each start. |
mergeFilter |
jq filter controlling how the pinned surface is merged. |
configFileName |
Basename of the runtime JSON config under stateDir. |
stateDir / workspaceDir |
Host bind-mount dirs (created + chowned). |
runUid / runGid |
In-container uid/gid; state dirs are chowned to these — must match the image (default 1000). |
containerName |
Name of the running container, used for rm/stop (defaults to name). |
envFile |
Optional --env-file path for secrets. |
buildTimeout |
TimeoutStartSec for the one-shot build unit (default 60min). |
Caveats¶
- Registry-free by design. Source arrives out-of-band; the module never
fetches from the network. If you want reproducible builds, consider
pkgs.dockerTools.buildImageinstead — this module targets the case where you have an existing Dockerfile you'd rather not port. pinnedConfigshould hold only the reproducibility-critical keys. Put volatile operator state (tokens, sessions) outside it, or the merge will keep stomping it.- Never put secrets in
pinnedConfig. It is serialized to a JSON file in the world-readable Nix store (mode0444), so any local user can read it. Keep API keys, tokens, and passwords out of it — route them throughenvFile(a runtime path such as/run/secrets/...), which is the only secret-bearing input this module reads at runtime rather than baking into the store. - The config file lives inside
stateDirand is chmod600. If your image reads it from a different path, adjust the bind-mount indockerArgsandconfigFileNameso both point at the same file. - An empty allowlist may mean "allow everything" in some apps, not "allow
nothing" — check your app's semantics before shipping an empty
pinnedConfig. - It turns Docker on for you. Enabling the module sets
virtualisation.docker.enableandvirtualisation.docker.autoPrune.enable. The pruner only reaps dangling images, so the tagged build survives — but old tags you bump away from are yours to clean up.
Source¶
modules/local-built-docker-service-with-nix-pinned-config-merge/default.nix
# local-built-docker-service-with-nix-pinned-config-merge
#
# Run a self-hosted app from a Docker image built ON the host (no registry)
# and keep the security-relevant slice of its runtime JSON config reproducible
# in Nix by jq-merging it into the operator's hand-edited config on every start.
#
# See README.md for the why and the traps. This module is generic: point it at
# a source tree containing a Dockerfile, give the image a tag, and declare the
# pinned config surface. Everything private has been parameterized as options.
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.services.localDockerApp;
# The pinned surface, rendered to a JSON file in the store. Only these keys
# are authoritative from Nix; the merge below leaves everything else in the
# operator's runtime config untouched.
pinnedConfigFile = pkgs.writeText "${cfg.name}-pinned.json" (builtins.toJSON cfg.pinnedConfig);
# Merges the pinned surface into the runtime config file on every start.
# Split ownership: the operator owns the file (auth tokens, model choices,
# agents, …); Nix owns only the keys in `pinnedConfig`, re-applied each start
# so they can never drift out of version control.
mergeScript = pkgs.writeShellScript "${cfg.name}-merge-config" ''
set -eu
f=${cfg.stateDir}/${cfg.configFileName}
if [ ! -f "$f" ]; then
echo '{}' > "$f"
chown ${toString cfg.runUid}:${toString cfg.runGid} "$f"
chmod 600 "$f"
fi
tmp=$(mktemp)
${pkgs.jq}/bin/jq ${escapeShellArg cfg.mergeFilter} \
--slurpfile pinned ${pinnedConfigFile} \
"$f" > "$tmp"
mv "$tmp" "$f"
chown ${toString cfg.runUid}:${toString cfg.runGid} "$f"
chmod 600 "$f"
'';
runArgs =
[
"--rm"
"--name"
cfg.containerName
]
++ cfg.dockerArgs
++ optional (cfg.envFile != null) "--env-file=${cfg.envFile}"
++ [ cfg.imageTag ]
++ cfg.command;
dockerRun = pkgs.writeShellScript "${cfg.name}-run" ''
exec ${pkgs.docker}/bin/docker run \
${concatMapStringsSep " \\\n " escapeShellArg runArgs}
'';
in
{
options.services.localDockerApp = {
enable = mkEnableOption "a locally-built (registry-free) Docker service with a Nix-pinned config surface";
name = mkOption {
type = types.str;
default = "localapp";
description = ''
Short identifier used to name the systemd units, state dir, and
rendered store files. Change it if you run more than one instance.
'';
};
runUid = mkOption {
type = types.int;
default = 1000;
description = ''
Numeric uid the container process runs as. State files are chowned to
this so the container can read/write them. Must match the uid baked
into the image (many node/python images use 1000). This is an
image-specific knob — inspect your image if writes fail with EACCES.
'';
};
runGid = mkOption {
type = types.int;
default = 1000;
description = "Numeric gid the container process runs as. See runUid.";
};
sourceDir = mkOption {
type = types.str;
example = "/srv/myapp/src";
description = ''
Path to the source checkout used as the docker build context. Expected
to contain a Dockerfile. Sync it in out-of-band (rsync, a deploy step,
a git checkout on the host) — this module does not fetch source from
the network.
'';
};
imageTag = mkOption {
type = types.str;
example = "myapp:local-1.0.0";
description = ''
Tag applied to the locally-built image. TRAP: the build is skipped when
this tag already exists, so a code change alone rebuilds nothing — bump
the tag to force a rebuild.
'';
};
stateDir = mkOption {
type = types.str;
default = "/var/lib/${cfg.name}/state";
description = "Host directory bind-mounted for persistent state (holds the runtime config file).";
};
workspaceDir = mkOption {
type = types.str;
default = "/var/lib/${cfg.name}/workspace";
description = "Host directory bind-mounted for the app's working data (optional; created and chowned like stateDir).";
};
containerName = mkOption {
type = types.str;
default = cfg.name;
description = "Name given to the running container (used for rm/stop).";
};
configFileName = mkOption {
type = types.str;
default = "config.json";
description = "Basename of the runtime JSON config, relative to stateDir.";
};
pinnedConfig = mkOption {
type = types.attrsOf types.anything;
default = { };
example = literalExpression ''
{
# e.g. a capability/skill allowlist — the security-relevant surface
allowList = [ "read-files" "run-shell" ];
entries."http-fetch" = {
enabled = true;
env.BASE_URL = "https://api.example.com/v1";
};
}
'';
description = ''
The slice of runtime config that Nix owns. Rendered to JSON and merged
into the runtime file on every start via `mergeFilter`. Keep only the
security-relevant / reproducibility-critical keys here; leave volatile
operator state (tokens, sessions) out so their hand-edits survive.
SECRETS WARNING: `pinnedConfig` is serialized to a JSON file in the
world-readable Nix store (mode 0444) — every local user can read it.
Never put credentials (API keys, tokens, passwords) in it. Route those
through `envFile` (a runtime path such as `/run/secrets/...`) instead.
'';
};
mergeFilter = mkOption {
type = types.str;
default = ". * $pinned[0]";
description = ''
jq filter that merges the pinned surface (available as `$pinned[0]`)
into the existing runtime config (the input `.`). The default does a
recursive deep merge (`*`), so pinned keys win but sibling operator
keys survive. Override for surgical control — e.g. to authoritatively
replace a single subtree rather than deep-merge it:
.skills = ((.skills // {}) + { allowBundled: $pinned[0].allowBundled,
entries: $pinned[0].entries })
'';
};
command = mkOption {
type = types.listOf types.str;
default = [ ];
example = [ "node" "dist/index.js" "gateway" "--bind" "loopback" ];
description = "Command (and args) to run inside the container, appended after the image tag.";
};
dockerArgs = mkOption {
type = types.listOf types.str;
default = [
"--network=host"
"--cap-drop=NET_RAW"
"--cap-drop=NET_ADMIN"
"--security-opt=no-new-privileges:true"
"--init"
];
description = ''
Extra `docker run` flags (volumes, env, network, security opts). The
default set is a locked-down host-network profile: `--network=host` is
handy when the app must reach a loopback-only backend, and the dropped
caps + no-new-privileges claw back part of what host networking gives
away. Include your `-v ${cfg.stateDir}:...` and `-e ...` flags here.
'';
};
envFile = mkOption {
type = types.nullOr types.path;
default = null;
description = "Optional path passed to `docker run --env-file`.";
};
buildTimeout = mkOption {
type = types.str;
default = "60min";
description = "systemd TimeoutStartSec for the one-shot build unit.";
};
};
config = mkIf cfg.enable {
virtualisation.docker = {
enable = true;
autoPrune.enable = true;
};
systemd.tmpfiles.rules = [
"d /var/lib/${cfg.name} 0755 ${toString cfg.runUid} ${toString cfg.runGid} -"
"d ${cfg.stateDir} 0700 ${toString cfg.runUid} ${toString cfg.runGid} -"
"d ${cfg.workspaceDir} 0700 ${toString cfg.runUid} ${toString cfg.runGid} -"
];
# One-shot builder: builds the image from the on-host source context.
# TRAP: skips when imageTag already exists — bump the tag to rebuild.
systemd.services."${cfg.name}-build" = {
description = "${cfg.name} — build local docker image from ${cfg.sourceDir}";
after = [
"docker.service"
"local-fs.target"
];
requires = [ "docker.service" ];
wantedBy = [ "multi-user.target" ];
path = with pkgs; [
docker
bash
coreutils
];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
TimeoutStartSec = cfg.buildTimeout;
};
script = ''
set -eu
if [ ! -f ${cfg.sourceDir}/Dockerfile ]; then
echo "[${cfg.name}-build] no Dockerfile at ${cfg.sourceDir} — sync source first." >&2
exit 1
fi
if docker image inspect ${cfg.imageTag} >/dev/null 2>&1; then
echo "[${cfg.name}-build] ${cfg.imageTag} already present; skipping build."
exit 0
fi
echo "[${cfg.name}-build] building ${cfg.imageTag} from ${cfg.sourceDir}"
docker build -t ${cfg.imageTag} ${cfg.sourceDir}
'';
};
systemd.services.${cfg.name} = {
description = "${cfg.name} service (local docker image)";
after = [
"docker.service"
"${cfg.name}-build.service"
];
requires = [
"docker.service"
"${cfg.name}-build.service"
];
wantedBy = [ "multi-user.target" ];
path = with pkgs; [ docker ];
serviceConfig = {
Type = "simple";
Restart = "always";
RestartSec = 10;
TimeoutStartSec = "5min";
ExecStartPre = [
# leading '-' so a missing container is not an error
"-${pkgs.docker}/bin/docker rm -f ${cfg.containerName}"
# re-apply the Nix-pinned config surface before every start
"${mergeScript}"
];
ExecStart = dockerRun;
ExecStop = "${pkgs.docker}/bin/docker stop ${cfg.containerName}";
};
};
};
}