VGI Injector: A Tiny HTTPS Download-and-Execute Binary in Zig
I needed a self-contained binary that downloads a program over HTTPS and exec's it — small enough to run in a FROM scratch container with nothing else. Go and Rust couldn't get small enough. Zig could.
The Problem
At Query.Farm I deploy services in FROM scratch containers — no shell, no libc, no package manager, nothing. Just a single static binary. This keeps the attack surface minimal and the image size tiny.
But sometimes the binary I want to run isn’t the binary I want to bake into the container image. I need an injector: a small program that downloads the real binary over HTTPS at startup and exec’s it. The injector has to be entirely self-contained — it can’t rely on a DNS resolver, a CA certificate store, or any shared libraries, because none of those exist in a scratch container.
The requirements:
- Download a binary from an HTTPS URL (with TLS certificate validation — no shortcuts)
- DNS resolution built in, including IPv6 DNS servers
- Retry with exponential backoff on both DNS and HTTP failures
- Execute the downloaded binary without writing to the filesystem
- As small as possible
Why Not Go or Rust?
I tried both.
Go produces a ~4.5 MB stripped binary for this use case. The HTTP and TLS standard library is comprehensive but heavy, and starting with Go 1.24, the runtime includes a mandatory FIPS 140-3 cryptography module that adds ~1.5 MB with no way to opt out. CGO_ENABLED=0 gets you a static binary, but the size floor is high.
Rust with reqwest (blocking, rustls-tls), LTO, opt-level = "z", codegen-units = 1, panic = "abort", and strip comes in at ~1.2 MB. Better, but still larger than I wanted.
Zig produces a ~460 KB stripped binary — and ~220 KB after UPX compression. Zig’s standard library includes a TLS 1.3 implementation and HTTP client with no external dependencies. The binary targets x86_64-linux-none (freestanding, no libc ABI), which means it genuinely has zero runtime dependencies. Cross-compilation from macOS to Linux is a single flag.
How It Works
The injector does five things:
-
Read configuration from environment variables —
VGI_INJECTOR_URL(required) is the HTTPS URL to download.VGI_INJECTOR_DNS(optional, default1.1.1.1) is the DNS server to use. -
Resolve the hostname via DNS — raw UDP queries to the configured DNS server. This supports both IPv4 and IPv6 DNS servers, which matters on platforms like Fly.io where the internal DNS server is an IPv6 address (
fdaa::3). Retries with exponential backoff, 3 attempts. -
Download the binary over HTTPS — connects to the resolved IP with TLS SNI set to the original hostname. The full Mozilla CA certificate bundle is embedded at compile time via Zig’s
@embedFile. Retries with exponential backoff, 3 attempts. -
Write to memfd — instead of writing to a temporary file (which would require a filesystem), the binary is written to a memory-backed file descriptor using
memfd_create. No filesystem needed at all. -
Exec — the process replaces itself with the downloaded binary via
execveon/proc/self/fd/N. All arguments, environment variables, and file descriptors pass through.
The entire implementation is a single Zig file — no dependencies beyond the standard library.
The CA Bundle
One thing I had to think about: where do the TLS root certificates come from? In a scratch container there’s no /etc/ssl/certs/. The solution is to embed the full Mozilla CA bundle into the binary at compile time using @embedFile("ca-certificates.crt").
The bundle is ~326 KB of PEM data, which is a significant chunk of the final binary. But it’s necessary — without it, there’s no way to validate the server’s certificate chain.
To keep things fresh, the CA bundle isn’t checked into source control. A shell script (update-ca-bundle.sh) downloads it from curl.se/ca/cacert.pem if the local copy is missing or older than 7 days. CI runs this before every build.
Build
The build is straightforward. Zig cross-compiles to Linux from any platform:
# Download CA bundle (required before first build)
./update-ca-bundle.sh
# Build for amd64
zig build
# Build for arm64
zig build -Darch=aarch64
# Build with a version string baked in
zig build -Dversion=v0.3.0
The build system supports embedding a version string via Zig’s build options, which the binary logs on startup alongside a copyright notice.
Get It
VGI Injector is open source under Apache 2.0.
Source code: github.com/Query-farm/vgi-injector