bitcoind-in-nspawn-container¶
Modules
A NixOS module that runs bitcoind inside a privateNetwork systemd-nspawn
container, with the chain data bind-mounted from the host.
The problem¶
bitcoind is an internet-facing daemon: it listens for inbound peer
connections and talks to arbitrary nodes on the public network. If it is
compromised you would rather the blast radius stop at the daemon than reach the
rest of the machine. But you also do not want to redo a multi-hundred-GiB chain
sync every time you rebuild the service.
This module reconciles the two: bitcoind lives in an isolated container with its own network namespace, while its data directory lives on the host and is bind-mounted in.
The key insight: same uid/gid on both sides¶
The chain data is bind-mounted from the host (dataDir) into the container at
/data. A bind mount shares the inodes, including their numeric owner — there
is no uid translation. So the service user must be declared with the same
uid and gid on the host and inside the container. If they differ, the files
show up owned by the wrong user (or nobody) on one side, and bitcoind either
can't read its own chainstate or silently writes files the host can't manage.
That is why this module declares the service user twice — once on the host, once
in the container config (both keyed on name, which also names the container,
the group and the systemd unit) — both pinned to cfg.uid / cfg.gid. Don't let
NixOS auto-allocate the uid; pin it.
Making the module flexible: settings¶
The typed options (network, rpc.*, uid/gid, containerNetwork.*, ...)
only cover the flags this module's author anticipated. For everything else
there is settings, an RFC-42-style free-form option (the same pattern
nixpkgs uses for services.postgresql.settings / services.grafana.settings)
declared as:
settings = mkOption {
type = types.submodule {
freeformType = types.attrsOf (types.oneOf [ types.bool types.int types.str (types.listOf ...) ]);
};
default = { };
};
Each attribute of settings becomes a bitcoind command-line argument:
boolbecomes1/0. This is a trap: bitcoind's own CLI parser does not understand the wordstrue/falseas booleans at all — passing-blocksonly=trueis a silent no-op-turned-parse-surprise, not a booleantrue. bitcoind's own convention is1/0, so this module converts for you:settings.blocksonly = true;renders as-blocksonly=1.intandstrare stringified as-is.- a list repeats the flag once per element — this is bitcoind's own
convention for "list" arguments such as
-rpcallowip,-connect,-addnode,-whitelist:settings.whitelist = [ "10.0.0.0/24" "10.0.0.5" ];renders as-whitelist=10.0.0.0/24 -whitelist=10.0.0.5.
Worked example: a flag the typed options do not cover¶
Say you want to cap memory use with -dbcache, run in block-relay-only mode,
and whitelist a subnet — none of which have a typed option:
services.bitcoindContainer.settings = {
dbcache = 4096;
blocksonly = true;
whitelist = [ "10.0.0.0/24" ];
};
renders as -dbcache=4096 -blocksonly=1 -whitelist=10.0.0.0/24, with no fork
of this module required.
Precedence (explicit, and matches the code)¶
Args are built in this order: typed options → settings → extraArgs.
bitcoind's argument parser keeps the last occurrence of a scalar flag, so:
- A key in
settingsoverrides the same key derived from a typed option (e.g.settings.rpcportwins overrpc.portif both are set — don't do that, but if you do,settingswins). - A key in
extraArgsoverrides the same key set viasettings—extraArgsis kept as the final, untyped escape hatch for backward compatibility and for flags that need no=valueat all. - List-style flags (
-rpcallowip,-whitelist, ...) do not override this way; occurrences from different sources accumulate. Setting bothrpc.allowedIPsandsettings.rpcallowipadds both sets of addresses rather than one replacing the other.
extraArgs keeps working exactly as before — it is not deprecated. settings
is additive: it exists so an adopter reaches for a plain Nix attribute set
instead of hand-building -key=value strings, while extraArgs remains for
one-off or valueless flags.
Why CLI args, not a generated bitcoin.conf¶
This module has never generated a bitcoin.conf — it has always assembled a
flat list of -key=value CLI arguments (see bitcoindArgs in default.nix)
and passed them straight to ExecStart. settings continues that: it is
rendered into the same CLI-arg list, not into a config file. Two reasons:
- Zero behavior change. Introducing a config file would mean adding
-conf=/pathinside the container, deciding where that file lives relative to the/databind mount, and reasoning about read order between a file and CLI args. Staying with pure CLI args keeps today'sExecStartline — and every current consumer's resolved arguments — byte-for-byte unchanged. bitcoin.confhas network-section semantics that CLI args do not. A config file lets you scope a setting to[main],[test], or[regtest]so the same file behaves differently per network. A CLI flag has no such section — it always applies regardless of which network-mainnet/-testnet/-regtestselects. That's exactly consistent with how this module already treatsnetworkandrpc.*: they are global CLI flags, not network-scoped. Addingsettingsas CLI args preserves that existing, simpler mental model (one flat arg list, no per-network sections) instead of introducing a second, section-aware configuration surface next to it. If you need genuinely network-scoped settings, that would be a deliberate future extension (e.g. a generatedbitcoin.confalongside-conf=), not somethingsettingsdoes today.
Traps this encodes¶
-
Shutdown timeout. On stop, bitcoind flushes its UTXO set (the
chainstate) to disk. On a large node this takes minutes. If systemdSIGKILLs it first you can corrupt the chainstate and be forced into a long reindex. The service sets a longTimeoutStopSec(stopTimeoutSec, default 300s). Do not shorten it casually. -
DNS.
privateNetwork = truegives the container a fresh network namespace with no inherited resolver. Its only route to the outside is the host side of the veth pair, so the container's single nameserver is set tocontainerNetwork.hostAddress. That means the host must be able to resolve DNS on that address (a stub resolver like systemd-resolved, or a forwarder, listening there). If bitcoind can't resolve DNS seeds it won't find peers. -
W^X hardening. The service runs with
MemoryDenyWriteExecute = true. AnyextraArgsthat loads a JIT plugin will fault. If you need one, relax the hardening.
Usage¶
{
imports = [ ./modules/bitcoind-in-nspawn-container ];
services.bitcoindContainer = {
enable = true;
dataDir = "/persist/bitcoind"; # put this on persistent storage
network = "mainnet";
rpc = {
enable = true;
port = 8332;
# to reach RPC from the host, allow the host-side veth, not just loopback
allowedIPs = [ "127.0.0.1" "192.168.203.1" ];
};
# anything the typed options above don't model — see "Making the module
# flexible: `settings`" above for the full trap/precedence writeup
settings = {
dbcache = 4096;
blocksonly = true; # -> -blocksonly=1, NOT -blocksonly=true
};
};
}
Options¶
| Option | Default | Purpose |
|---|---|---|
enable |
false |
Turn the container on. |
name |
bitcoind |
Base name for the container, the host/container service user + group, and the systemd unit. Change it only to coexist with, or extend, resources under a specific name. |
package |
pkgs.bitcoin |
The bitcoind package to run. |
dataDir |
/var/lib/bitcoind |
Host path bind-mounted to /data. |
uid / gid |
1320 |
Service user identity — identical host + container. |
network |
mainnet |
mainnet | testnet | regtest. |
rpc.enable |
false |
Enable the JSON-RPC server. |
rpc.port |
8332 |
RPC port (opened in the container firewall). |
rpc.allowedIPs |
[ "127.0.0.1" ] |
-rpcallowip entries. |
containerNetwork.hostAddress |
192.168.203.1 |
Host side of the veth; also the container's nameserver. |
containerNetwork.localAddress |
192.168.203.2 |
Container side of the veth. |
stopTimeoutSec |
300 |
TimeoutStopSec — long enough to flush chainstate. |
settings |
{ } |
Free-form -key=value flags for anything above doesn't cover (see "Making the module flexible" above). |
extraArgs |
[ ] |
Extra bitcoind flags, rendered after settings (mind the W^X trap). |
Caveats¶
- Reaching RPC from the host means allowing a veth address in both
rpc.allowedIPsand, if you firewall the host, the corresponding path. RPC is not exposed outside the host by default. - RPC is bound explicitly to loopback and the container-side veth
(
containerNetwork.localAddress), not to all interfaces.rpc.allowedIPsis the source-IP filter on top of that. If you widenallowedIPsto reach RPC over some other interface, remember the socket still only listens on those two addresses — add a matching-rpcbind=<addr>viaextraArgs. Never bind RPC to a public address;-rpcallowipis not a substitute for network isolation. - The container
system.stateVersionis pinned at24.05; bump it deliberately, not incidentally, since it governs stateful defaults inside the container. - This isolates the network and filesystem, not the kernel. nspawn shares the host kernel; it is not a hypervisor boundary.
Source¶
modules/bitcoind-in-nspawn-container/default.nix
# bitcoind-in-nspawn-container
#
# Run an internet-facing bitcoind inside a privateNetwork systemd-nspawn
# container, with the chain data bind-mounted from the host so a full sync
# survives container rebuilds. The service user is declared with the SAME
# uid/gid on BOTH sides of the bind mount so on-disk ownership stays valid
# across the container boundary.
#
# Import this module and set `services.bitcoindContainer.enable = true;`.
#
# See README.md for the traps this encodes (shutdown timeout, DNS, W^X).
{
config,
lib,
pkgs,
...
}:
with lib;
let
cfg = config.services.bitcoindContainer;
# bitcoind's own CLI convention for booleans is 1/0, NOT true/false — it
# does not parse the words "true"/"false" as a boolean at all.
settingValueToStr =
v:
if isBool v then
(if v then "1" else "0")
else if isInt v then
toString v
else if isString v then
v
else
throw "services.bitcoindContainer.settings: unsupported value type (${builtins.typeOf v}) for a setting; use bool, int, str, or a list of those";
# A list value repeats the flag once per element, matching bitcoind's
# convention for "list" args such as -rpcallowip/-connect/-addnode.
settingsToArgs =
settings:
concatLists (
mapAttrsToList (
name: value:
if isList value then
map (v: "-${name}=${settingValueToStr v}") value
else
[ "-${name}=${settingValueToStr value}" ]
) settings
);
in
{
options.services.bitcoindContainer = {
enable = mkEnableOption "bitcoind running inside an isolated nspawn container";
name = mkOption {
type = types.str;
default = "bitcoind";
description = ''
Base name used for the nspawn container, the host- and container-side
service user/group, and the systemd bitcoind service unit. Change it
only if you need to coexist with, or extend, resources under a specific
name (e.g. a host that attaches extra bind mounts and companion services
to `containers.<name>` and orders against `<name>.service`).
'';
};
package = mkOption {
type = types.package;
default = pkgs.bitcoin;
defaultText = literalExpression "pkgs.bitcoin";
description = "The bitcoind package to run inside the container.";
};
dataDir = mkOption {
type = types.str;
default = "/var/lib/bitcoind";
description = ''
Host directory holding the blockchain data and configuration. It is
bind-mounted into the container at /data. Put it on persistent storage;
a full mainnet sync is hundreds of GiB and you do not want to redo it
when the container is rebuilt.
'';
};
uid = mkOption {
type = types.int;
default = 1320;
description = ''
User ID for the bitcoind service user. It MUST be identical on the host
and inside the container, otherwise the files under dataDir will appear
owned by the wrong user on one side of the bind mount.
'';
};
gid = mkOption {
type = types.int;
default = 1320;
description = ''
Group ID for the bitcoind service group. Like uid, it must match on both
sides of the bind mount.
'';
};
network = mkOption {
type = types.enum [
"mainnet"
"testnet"
"regtest"
];
default = "mainnet";
description = "Bitcoin network to connect to.";
};
rpc = {
enable = mkOption {
type = types.bool;
default = false;
description = "Enable the bitcoind JSON-RPC server.";
};
port = mkOption {
type = types.port;
default = 8332;
description = "bitcoind RPC port (opened in the container firewall when RPC is enabled).";
};
allowedIPs = mkOption {
type = types.listOf types.str;
default = [ "127.0.0.1" ];
example = [ "192.168.203.1" ];
description = ''
IPs/subnets passed to -rpcallowip. To reach RPC from the host, allow
the container-side or host-side veth address, not just loopback.
'';
};
};
containerNetwork = {
hostAddress = mkOption {
type = types.str;
default = "192.168.203.1";
description = ''
Host side of the container's veth pair. This doubles as the
container's only nameserver (see below), so the host must be able to
resolve DNS on this address.
'';
};
localAddress = mkOption {
type = types.str;
default = "192.168.203.2";
description = "Container side of the veth pair.";
};
};
stopTimeoutSec = mkOption {
type = types.int;
default = 300;
description = ''
TimeoutStopSec for bitcoind. On shutdown bitcoind flushes its UTXO set
(chainstate) to disk, which can take minutes on a large node. If systemd
SIGKILLs it before the flush completes you can corrupt the chainstate and
force a lengthy reindex. Keep this generously long.
'';
};
settings = mkOption {
type = types.submodule {
freeformType = types.attrsOf (
types.oneOf [
types.bool
types.int
types.str
(types.listOf (
types.oneOf [
types.bool
types.int
types.str
]
))
]
);
};
default = { };
example = literalExpression ''
{
# keys the typed options above do not model at all
maxconnections = 40;
dbcache = 4096;
blocksonly = true; # -> -blocksonly=1
whitelist = [ "192.168.203.0/24" "10.0.0.5" ]; # -> repeated -whitelist=
}
'';
description = ''
Free-form bitcoind settings for anything the typed options above do
not cover, so an adopter never has to fork this module for a flag its
author did not anticipate. Each attribute becomes a bitcoind
`-key=value` command-line argument:
- `bool` becomes `1`/`0` — bitcoind's own convention, NOT `true`/`false`.
- `int` and `str` are stringified as-is.
- a list repeats the flag once per element (bitcoind's convention for
"list" args like `-rpcallowip`/`-connect`/`-addnode`/`-whitelist`).
Precedence: `settings` is rendered AFTER the typed options above
(`network`, `rpc.*`, ...) and BEFORE `extraArgs`. bitcoind's argument
parser keeps the LAST occurrence of a scalar flag, so a key in
`settings` overrides the same key derived from a typed option, and a
key in `extraArgs` overrides the same key set via `settings`. List-style
flags do not override this way — occurrences accumulate — so e.g.
`rpc.allowedIPs` and `settings.rpcallowip` would both take effect
rather than one replacing the other.
'';
};
extraArgs = mkOption {
type = types.listOf types.str;
default = [ ];
example = [ "-zmqpubrawblock=tcp://127.0.0.1:28332" ];
description = ''
Extra command-line arguments for bitcoind, rendered last (after
`settings`), so this is the final override if you need one. Prefer
`settings` for plain `-key=value`/repeated-key flags; `extraArgs`
still accepts anything, including flags with no value. Note that the
service runs with MemoryDenyWriteExecute, so any argument that pulls
in a JIT plugin will fault — disable the hardening if you need one.
'';
};
};
config = mkIf cfg.enable {
# Host-side declaration of the service user. Declared with the same uid/gid
# as inside the container so the bind-mounted data has consistent ownership.
users = {
users.${cfg.name} = {
uid = cfg.uid;
isSystemUser = true;
group = cfg.name;
description = "bitcoind node service user";
home = cfg.dataDir;
};
groups.${cfg.name}.gid = cfg.gid;
};
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0700 ${cfg.name} ${cfg.name} - -"
];
containers.${cfg.name} = {
autoStart = true;
# privateNetwork gives the container its own network namespace: the
# internet-facing daemon cannot reach the host's other services.
privateNetwork = true;
inherit (cfg.containerNetwork) hostAddress localAddress;
config =
{ ... }:
let
bitcoindArgs = [
"-datadir=/data"
"-printtoconsole"
]
++ optional (cfg.network != "mainnet") "-${cfg.network}"
++ optionals cfg.rpc.enable (
[
"-server"
"-rpcport=${toString cfg.rpc.port}"
# Without -rpcbind, -rpcallowip alone makes bitcoind bind RPC on ALL
# container interfaces (0.0.0.0/::) and act only as an IP filter. Bind
# explicitly to loopback + the container-side veth (the address the
# host reaches RPC on) so the socket is not exposed on every address.
"-rpcbind=127.0.0.1"
"-rpcbind=${cfg.containerNetwork.localAddress}"
]
# -rpcallowip is repeatable and each value parses as ONE subnet
# spec; there is no comma-separated form. Emit one flag per entry,
# exactly as settingsToArgs does for list-valued settings.
++ map (ip: "-rpcallowip=${ip}") cfg.rpc.allowedIPs
)
++ settingsToArgs cfg.settings
++ cfg.extraArgs;
in
{
nixpkgs.pkgs = pkgs;
system.stateVersion = "24.05";
# Same uid/gid as the host user — this is the whole point.
users.users.${cfg.name} = {
uid = cfg.uid;
isSystemUser = true;
home = "/data";
group = cfg.name;
description = "bitcoind node service user";
};
users.groups.${cfg.name}.gid = cfg.gid;
networking = {
# With privateNetwork the container has no inherited resolver. Its
# only route out is the host side of the veth, so point DNS there;
# the host must actually resolve for it.
nameservers = [ cfg.containerNetwork.hostAddress ];
firewall = {
enable = true;
allowedTCPPorts = optionals cfg.rpc.enable [ cfg.rpc.port ];
};
};
systemd.services.${cfg.name} = {
description = "bitcoind node";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
Type = "simple";
User = cfg.name;
Group = cfg.name;
ExecStart = "${cfg.package}/bin/bitcoind ${concatStringsSep " " bitcoindArgs}";
Restart = "on-failure";
TimeoutStartSec = "0";
# Give bitcoind time to flush the UTXO set on stop — see option doc.
TimeoutStopSec = toString cfg.stopTimeoutSec;
PrivateTmp = true;
ProtectSystem = "full";
NoNewPrivileges = true;
MemoryDenyWriteExecute = true;
};
};
environment.systemPackages = [ cfg.package ];
};
bindMounts = {
"/data" = {
hostPath = cfg.dataDir;
isReadOnly = false;
};
};
};
};
}