nimstatic
Fully static Nim binaries, dependencies and all.
nimstatic app.nim
That's the whole thing. nimstatic asks the Nim compiler what app.nim actually
needs, fetches musl-built static libraries for it from Alpine's mirrors, and
compiles against a sysroot it owns. No apk, no container, no root, no Nix.
$ nimstatic freeqsay.nim -- -d:ssl
probing freeqsay.nim …
ssl dlopen openssl-libs-static
crypto dlopen openssl-libs-static
downloading openssl-libs-static 3.3.7-r1 (12734 KiB)
building …
wrote freeqsay (20687 KiB, static)
The result runs anywhere with a Linux kernel — ldd says not a dynamic
executable — and its HTTPS still works.
How it knows what you need
Guessing from import lines would be wrong in both directions: a transitive
import three modules deep still needs its library, and an import behind a
when that never fires does not. So nimstatic asks the compiler instead. A
--compileOnly --genScript probe produces the nimcache your real build would
have, and that cache answers twice over:
<project>.jsoncarries the link command, so every-lfoois explicit.- The generated C contains the dynlib candidate strings Nim will
dlopen
at runtime —"libssl.so(.3|.1.1|…)". These never appear on a link line, and
they are exactly what breaks a static binary.
Libraries musl already provides (m, rt, dl, pthread, …) are skipped.
Anything else is looked up in a table of Alpine packages; whatever isn't mapped
is reported rather than silently dropped:
$ nimstatic detect app.nim
library how alpine package
sqlite3 link sqlite-static
ssl dlopen openssl-libs-static
mystery dlopen (unmapped)
unmapped: mystery
search for one with: nimstatic search mystery
Then --map mystery=mystery-static teaches it, for that build.
The two things that silently break static Nim
Both are handled automatically; they're documented here because they cost
everyone an afternoon at least once.
-d:sslmakes Nimdlopenlibssl at runtime. A static binary cannot,
and dies at startup withcould not load: libcrypto.so(...)— even on code
paths that never touch the network. Fix:--dynlibOverride:ssl --dynlibOverride:cryptoplus the archives on the link line. nimstatic emits
an override only for libraries it actually has an archive for, since an
override without one turns a runtime failure into a link failure.- OpenSSL 3 removed
SSL_get_peer_certificate, which Nim's wrapper still
names, so the link dies on one undefined symbol. Fix:
-DSSL_get_peer_certificate=SSL_get1_peer_certificate.
A static binary also carries no CA trust store, so set SSL_CERT_FILE on the
host that runs it.
Usage
nimstatic <file.nim> [-- <nim args>] Detect, fetch, build static
nimstatic detect <file.nim> Show what it needs, change nothing
nimstatic add <pkg>... Put packages in the sysroot by hand
nimstatic list Show what the sysroot holds
nimstatic libs Show the sysroot's static libraries
nimstatic search <text> Search Alpine's index
nimstatic show <pkg> Index record for one package
nimstatic nimflags [-l lib] Print nim flags for the sysroot
nimstatic ccflags [-l lib] Print cc/clang flags
nimstatic nimcfg [-o file] Write a nim.cfg fragment
nimstatic zigcc [-o file] Write a `zig cc -target …-musl` wrapper
nimstatic env Shell exports (PKG_CONFIG_*, NIMSTATIC_ROOT)
nimstatic path Print the sysroot path
nimstatic clean Remove the sysroot (cache is kept)
Everything after -- goes to the Nim compiler for both the probe and the
build, so conditional imports resolve the same way twice:
nimstatic app.nim -- -d:ssl -d:danger
Build options: -o/--output, -d/--debug (skip -d:release), -n/--dry-run
(print the command instead of running it), --map lib=pkg, --pkg name,
--cc path, --nim path.
Sysroot options: -r/--root, -b/--branch (default v3.21, edge for
rolling), -a/--arch, -m/--mirror, --repo main,community, -l/--lib,
--no-deps, --refresh, -q/--quiet.
The sysroot defaults to $XDG_DATA_HOME/nimstatic/sysroot and honors
NIMSTATIC_ROOT. Downloads cache under $XDG_CACHE_HOME/nimstatic, so the
second build is offline and the index is re-fetched once a day.
Cross-compiling
Same command, one flag — the sysroot, the packages and the zig target all
follow --arch:
nimstatic app.nim --arch aarch64 -o app-arm64
Requirements
- zig on PATH — used as
zig cc -target x86_64-linux-musl. Pass--ccto
use a musl cross-compiler you already have instead. - tar — an
.apkis concatenated gzip streams, which GNU tar reads.
Using it as a library
import nimstatic
let d = detect("app.nim", ["-d:ssl"])
echo packages(d) # @["openssl-libs-static"]
echo dynlibOverrides(d) # @["ssl", "crypto"]
let remote = initRemote(branch = "edge")
for pkg in remote.fetchIndex().resolve(packages(d)):
echo pkg.name, " ", pkg.version, " ", remote.fetchPackage(pkg)
Modules: nimstatic/detect (probe, parse, map), nimstatic/build (the
one-command path), nimstatic/index (APKINDEX parsing, provides and dependency
resolution), nimstatic/repo (mirror, cache, download, unpack),
nimstatic/sysroot (manifest, static-lib discovery), nimstatic/flags (flag
emission).
Trust
Packages come over HTTPS from the mirror and are unpacked as-is. nimstatic
checks the size recorded in the index but does not verify Alpine's RSA
signatures — apk's checksum field covers a package's control segment rather
than the file, so a real check means implementing apk's signature format. Treat
a sysroot as build input, not as a trust root. If that matters, pin a mirror
you run.
Install
nimble install
or build in place with nim c -d:ssl -o:nimstatic src/nimstatic.nim.
Tests
nimble test
25 tests, all offline: soname parsing, link-command parsing, dynlib discovery
in generated C, package mapping, APKINDEX parsing, dependency resolution
through so:/pkgconfig provides, manifest round-trips and flag emission
(including link order and the dropped-override case).