Build minimal Linux root filesystems from a base distribution and a declarative list of host binaries.
forge takes a base Alpine Linux root filesystem and a configuration describing the executables you need. It analyses those executables, resolves their runtime dependencies, and assembles everything into a self-contained rootfs.
The goal is simple:
Alpine
+
Selected binaries
+
Runtime dependencies
│
▼
┌───────────┐
│ forge │
└───────────┘
│
▼
Minimal rootfs
The resulting rootfs can then be consumed by container runtimes such as cage, used with namespaces directly, packaged into an image, or inspected and modified like any ordinary filesystem.
Traditional container images are generally built around package managers and Dockerfile-like build systems.
forge takes a different approach.
Instead of saying:
Install these packages.
you say:
I need these executables.
For example:
[base]
distribution = "alpine"
version = "3.22"
architecture = "x86_64"
[binaries]
paths = [
"/bin/sh",
"/usr/bin/curl",
"/usr/bin/git",
]forge determines what those programs require at runtime and assembles the necessary files into the rootfs.
This makes it possible to construct small, purpose-built environments without maintaining a package manifest for every dependency.
forge currently supports 64-bit little-endian Linux systems using:
| Configuration | ELF machine |
|---|---|
x86_64 |
EM_X86_64 |
aarch64 |
EM_AARCH64 |
riscv64 |
EM_RISCV |
The ELF parser currently operates on ELF64 binaries. 32-bit ELF binaries and other architectures are rejected explicitly.
The requested base architecture must match the architecture of the binaries being assembled. forge validates the ELF architecture of requested binaries, dependencies, and dynamic linkers before including them in the generated rootfs.
| Component | Responsibility |
|---|---|
| Configuration | Defines the base distribution and requested binaries |
| Base Rootfs | Provides the initial Alpine filesystem |
| ELF Parser | Inspects binaries and extracts runtime metadata |
| Dependency Resolver | Recursively resolves libraries and interpreters |
| Rootfs Assembler | Copies and reconstructs the required filesystem tree |
A build proceeds through a deterministic pipeline.
flowchart TD
A["forge.toml"] --> B["Parse configuration"]
B --> C["Prepare Alpine rootfs"]
C --> D["Validate requested binaries"]
D --> E["Inspect ELF"]
E --> F["Resolve interpreter"]
E --> G["Resolve DT_NEEDED"]
F --> H["Dependency graph"]
G --> H
H --> I["Resolve transitive dependencies"]
I --> J["Copy required files"]
J --> K["Construct output rootfs"]
K --> L["rootfs/"]
For example:
/usr/bin/curl
│
├── ELF interpreter
│
├── libcurl.so
│ ├── libssl.so
│ ├── libcrypto.so
│ └── libz.so
│
└── libc.so
forge turns this dependency graph into a filesystem:
rootfs/
├── bin/
│ └── sh
├── lib/
│ ├── ...
│ └── ld-linux-x86-64.so.2
└── usr/
├── bin/
│ ├── curl
│ └── git
└── lib/
├── libcurl.so.*
├── libssl.so.*
├── libcrypto.so.*
└── ...
The core of forge is its runtime dependency resolver.
For every requested executable, forge examines the ELF metadata:
ELF
├── Architecture
├── ABI
├── Program Headers
│ └── PT_INTERP
│
└── Dynamic Section
├── DT_NEEDED
├── DT_RPATH
├── DT_RUNPATH
└── ...
PT_INTERP identifies the dynamic linker:
/lib64/ld-linux-x86-64.so.2
while DT_NEEDED identifies shared-library dependencies:
libc.so.6
libcurl.so.4
libssl.so.3
These dependencies are resolved recursively.
graph TD
A["curl"] --> B["libcurl.so.4"]
A --> C["libc.so.6"]
B --> D["libssl.so.3"]
B --> E["libcrypto.so.3"]
B --> F["libz.so.1"]
D --> E
C --> G["ld-linux-x86-64.so.2"]
A visited set prevents dependencies from being processed more than once.
Conceptually:
resolve(binary):
if already visited:
return
mark visited
parse ELF
resolve interpreter
for dependency in DT_NEEDED:
resolve(dependency)
The dependency graph is therefore traversed once, giving approximately:
O(V + E)
where V is the number of files and E is the number of dependency relationships.
forge is intentionally capable of taking binaries from the host.
Given:
[binaries]
paths = [
"/usr/bin/curl"
]the binary is copied into the corresponding location inside the rootfs:
Host Rootfs
────────────────────────────────────────
/usr/bin/curl ───────► /usr/bin/curl
/usr/lib/libfoo.so ───────► /usr/lib/libfoo.so
/lib/libbar.so ───────► /lib/libbar.so
The original filesystem hierarchy is preserved.
This is important because executables generally expect their dependencies to exist at particular paths.
The initial implementation uses an Alpine minirootfs as the base.
flowchart LR
A["Alpine minirootfs"] --> C["forge"]
B["Host filesystem"] --> C
C --> D["Generated rootfs"]
The Alpine rootfs provides the basic userspace:
/
├── bin/
├── dev/
├── etc/
├── home/
├── lib/
├── proc/
├── root/
├── run/
├── sbin/
├── sys/
├── tmp/
├── usr/
└── var/
forge then augments this filesystem with the requested host software.
Host binaries are not universally compatible with an Alpine base.
Alpine traditionally uses musl libc, while many other Linux distributions use glibc.
For example:
Ubuntu binary
/usr/bin/foo
│
▼
ld-linux-x86-64.so.2
│
▼
glibc
whereas a native Alpine binary may use:
/usr/bin/foo
│
▼
ld-musl-x86_64.so.1
│
▼
musl
forge therefore treats the ELF interpreter and ABI as first-class information rather than assuming that every Linux executable is interchangeable.
The initial implementation targets a clearly defined host/base combination rather than attempting to transparently solve arbitrary ABI incompatibilities.
A configuration describes what should exist, not how it should be copied.
Example:
[base]
distribution = "alpine"
version = "3.22"
architecture = "x86_64"
[rootfs]
output = "./rootfs"
[binaries]
paths = [
"/bin/sh",
"/usr/bin/curl",
"/usr/bin/git",
"/usr/bin/python3",
]The configuration deliberately does not contain shared-library dependencies.
Those are derived automatically:
Configuration
│
▼
Requested binaries
│
▼
ELF analysis
│
▼
Dependency graph
│
▼
Required filesystem objects
A successful build produces an ordinary directory containing the root filesystem.
./rootfs/
├── bin/
├── dev/
├── etc/
├── home/
├── lib/
├── proc/
├── root/
├── run/
├── sbin/
├── sys/
├── tmp/
├── usr/
└── var/
There is intentionally no proprietary image format.
The output is just a filesystem.
This allows it to be consumed by other tools without requiring forge at runtime.
forge does not attempt to be a container runtime.
Its responsibility ends when the root filesystem has been constructed.
flowchart LR
A["forge.toml"] --> B["forge"]
B --> C["Rootfs"]
C --> D["Container runtime"]
D --> E["Namespaces"]
D --> F["Mounts"]
D --> G["Capabilities"]
D --> H["Process isolation"]
For example, cage can consume the result:
forge
│
▼
┌──────────────┐
│ rootfs │
└──────────────┘
│
▼
cage
│
┌───────────┼───────────┐
▼ ▼ ▼
namespaces OverlayFS isolation
│
▼
container
This separation keeps both projects small:
forge
Build the filesystem.
cage
Isolate and execute the process.
forge produces a directory tree rather than introducing another image format.
The configuration describes desired executables. Dependency discovery is automatic.
Only files required by the selected software should be introduced beyond the base filesystem.
Given the same base and host inputs, the builder should produce the same filesystem contents.
forge should fail rather than silently produce an environment where a binary cannot execute.
The output should be useful to cage and other container tooling without requiring knowledge of forge.
The project is intended to remain small and modular:
forge/
├── include/
│ ├── config.h
│ ├── alpine.h
│ ├── elf_parser.h
│ ├── resolver.h
│ ├── rootfs.h
│ └── ...
├── src/
│ ├── main.c
│ ├── config.c
│ ├── alpine.c
│ ├── elf.c
│ ├── resolver.c
│ ├── rootfs.c
│ └── ...
├── tests/
│ ├── config.c
│ ├── alpine.c
│ ├── elf.c
│ └── ...
├── examples/
│ └── minimal.toml
├── Makefile
├── README.md
└── LICENSE
- Define configuration format
- Validate requested host binaries
- Download and extract Alpine minirootfs
- Implement ELF parser
- Validate ELF architecture
- Extract
PT_INTERP - Extract
DT_NEEDED - Detect static binaries
- Detect incompatible dynamic linkers
- Add ELF dependency inspection CLI
- Implement host library resolution
- Implement recursive dependency graph
- Handle library search paths (
RPATH,RUNPATH, system paths) - Expand
$ORIGINin library search paths - Handle inherited
RPATH - Distinguish transitive
RPATHfrom non-transitiveRUNPATH - Detect missing dependencies
- Detect dependency cycles
- Resolve and validate required dynamic linkers
- Validate dependency ELF architecture
- Copy requested binaries into the rootfs
- Copy resolved libraries into the rootfs
- Copy required dynamic linker
- Preserve filesystem metadata
- Handle symlinked libraries
- Handle required parent directories
- Add support for static binaries
- Add support for scripts
- Detect script interpreters
- Add support for additional architectures
- Add deterministic builds
- Add build manifest
- Add caching
- Improve error reporting
forge should do one thing well:
"What do I need?"
│
▼
forge.toml
│
▼
forge
│
▼
"Here it is."
│
▼
rootfs/
It doesn't need to manage containers.
It doesn't need to manage processes.
It doesn't need to implement namespaces.
It doesn't need to be a package manager.
It takes a base filesystem, understands executable dependencies, and forges a runnable rootfs.