Terrarium is a secure container environment that uses Envoy as an L7 egress gateway, configured via familiar Cilium network policy semantics.
Terrarium allows you to declare policies that balance security and functionality, based on your risk tolerance, environment, and use cases.
It is particularly useful for running fully autonomous AI agents.
Published images include the terrarium binary and Envoy but not language runtimes, package managers, or other tools your workload needs. Use the image as a base layer or copy the binary into your own image.
The :debian variant ships with ca-certificates and Envoy
pre-installed:
FROM ghcr.io/macropower/terrarium:debian
RUN apt-get update && apt-get install -y python3 && rm -rf /var/lib/apt/lists/*
COPY config.yaml /home/dev/.config/terrarium/config.yaml
ENTRYPOINT ["terrarium", "init", "--"]
CMD ["python3", "app.py"]The :latest (scratch) variant contains only the terrarium binary,
which makes it useful as a copy source in a multi-stage build:
FROM ghcr.io/macropower/terrarium:latest AS terrarium
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates envoy && \
rm -rf /var/lib/apt/lists/*
COPY --from=terrarium /usr/local/bin/terrarium /usr/local/bin/terrarium
COPY config.yaml /home/dev/.config/terrarium/config.yaml
ENTRYPOINT ["terrarium", "init", "--"]
CMD ["/bin/bash"]However you build your image, terrarium init is the main entry point.
It sets up the firewall, DNS proxy, and Envoy, then drops privileges and
execs your command:
docker run --cap-add=NET_ADMIN my-terrarium-image--cap-add=NET_ADMIN is required for nftables. The default config path is ~/.config/terrarium/config.yaml (following XDG conventions); override it with --config. Use --ready-file to create a file once all infrastructure is up, useful for orchestration. See terrarium init --help for the full flag reference.
Allow GET requests to repos in your GitHub organization, injecting an API key header:
egress:
- toFQDNs:
- matchName: "github.com"
- matchPattern: "*.github.com"
toPorts:
- ports:
- port: "443"
protocol: TCP
rules:
http:
- method: "GET"
path: "/my-org/.*"
headerMatches:
- name: "Authorization"
mismatch: ADD
value: "Bearer ghp_xxxxxxxxxxxx"Allow DNS resolution for internal domains, plus HTTPS access:
egress:
- toFQDNs:
- matchName: "internal.example.com"
- matchPattern: "*.internal.example.com"
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchName: "internal.example.com"
- matchPattern: "*.internal.example.com"
- ports:
- port: "443"
protocol: TCPAllow TLS connections to specific SNIs within a CIDR range:
egress:
- toCIDR:
- "10.0.0.0/8"
toPorts:
- ports:
- port: "443"
protocol: TCP
serverNames:
- "db.internal.example.com"
- "*.internal.example.com"Allow access to the internet, deny access to your internal network:
egress:
- toCIDRSet:
- cidr: "0.0.0.0/0"
except:
- "10.0.0.0/8"
- "172.16.0.0/12"
- "192.168.0.0/16"Block specific ports to private subnets:
egressDeny:
- toCIDR:
- "192.168.0.0/16"
toPorts:
- ports:
- port: "22"
protocol: TCP
- toCIDRSet:
- cidr: "172.16.0.0/12"
except:
- "172.16.1.0/24"
toPorts:
- ports:
- port: "3306"
protocol: TCPForward non-TLS TCP services (like SSH) through Envoy to a named host:
tcpForwards:
- port: 22
host: "github.com"All processes share the same network namespace. Communication happens through kernel data structures (nftables sets via netlink) and loopback sockets.
graph TD
init["terrarium init\n(PID 1, root)"]
dns["DNS proxy\n(root, 127.0.0.1:53 + [::1]:53)"]
envoy["Envoy\n(UID 1001, CAP_NET_ADMIN)"]
user["User command\n(UID 1000, all caps dropped)"]
nft[("nftables\nFQDN IP sets")]
upstream(("Upstream"))
init --> dns
init --> envoy
init --> user
dns -- "updates via netlink\non resolution" --> nft
user -- "TCP: NAT REDIRECT\nUDP: TPROXY" --> nft
nft -- "FQDN traffic" --> envoy
nft -- "CIDR TCP traffic" --> envoy
nft -- "CIDR UDP traffic" --> envoy
nft -- "SCTP traffic" --> upstream
envoy --> upstream
Terrarium runs all components (firewall, DNS proxy, Envoy, user process) inside a single container sharing one network namespace. This isn't a packaging convenience; it's how the security model works.
nftables rules dispatch traffic based on process UID: the terrarium user (1000) gets full rule enforcement, Envoy (1001) gets unrestricted egress (domain allowlisting is enforced in Envoy's own config), and root (0) gets DNS access. Splitting into separate containers gives each its own network namespace, which breaks UID-based filtering entirely. You could share network namespaces across containers (a la Kubernetes Pods), but that adds orchestration complexity to arrive back at the same topology.
The components also depend on each other at runtime. The DNS proxy resolves allowed FQDNs and updates nftables IP sets via netlink. nftables redirects user traffic to Envoy's loopback listeners. The init process (PID 1) manages the lifecycle of all processes, forwards signals, and reaps zombies. Privilege drop happens atomically before exec'ing the user command.
There's also no real upside to splitting. Every component is 1:1 with the user workload, so independent scaling doesn't apply. The Envoy config and nftables rules are generated together from the same policy and can't drift independently, so independent upgrades don't work either. And fault isolation is actively undesirable here: if the DNS proxy or firewall crashes, the user process should stop. A security boundary with a hole is worse than no service at all.
The user command (UID 1000) shares a network namespace with the firewall, DNS proxy, and Envoy. Several kernel mechanisms prevent it from tampering with those components.
terrarium exec launches the user command with --inh-caps=-all and
--bounding-set=-all, which clears the entire Linux capability bounding set.
This is the foundational constraint; everything else is defense in depth.
Without capabilities, the user process cannot:
- Modify nftables rules or IP sets (
CAP_NET_ADMIN). - Bind to port 53 to replace the DNS proxy (
CAP_NET_BIND_SERVICE). - Send signals to Envoy (UID 1001) or init/DNS proxy (UID 0) (
CAP_KILL). - Change its own UID/GID or manipulate process credentials (
CAP_SETUID,CAP_SETGID).
The user process runs with --no-new-privs. The kernel enforces this across
execve: setuid/setgid bits are ignored and no capability can be raised, even
if a suid-root binary exists in the container image.
Envoy does not run with --no-new-privs because it needs CAP_NET_ADMIN as an
ambient capability. TPROXY requires IP_TRANSPARENT on the UDP listener socket,
and only CAP_NET_ADMIN can grant that. This is scoped: Envoy still runs as a
dedicated non-root user (UID 1001) with no other elevated capabilities.
All configuration and certificate files are created by root before the privilege drop. Envoy config is written 0644 (root-owned, world-readable); TLS certs and private keys are also 0644 (Envoy needs read access at UID 1001). The user process can read these files but cannot modify them.
Even if the user rewrites /etc/resolv.conf to point at an external DNS server,
nftables blocks the attempt. Port 53 egress is only allowed for root (UID 0);
UID 1000 traffic to external port 53 hits the terrarium_output chain's DROP. The
user's DNS queries reach 127.0.0.1:53 (the proxy) via loopback, which is
accepted before UID dispatch.
No single mechanism is load-bearing. Capability clearing prevents privilege escalation, no-new-privs blocks setuid exploitation, UID-based nftables rules enforce network policy regardless of process behavior, and file permissions prevent configuration tampering. Bypassing the security model requires compromising multiple independent kernel subsystems simultaneously.
The firewall, DNS proxy, and Envoy all derive their behavior from the same parsed policy. Three modes:
- Unrestricted (nil egress): all traffic allowed, routed through Envoy for access logging via NAT REDIRECT (TCP) and TPROXY (UDP).
- Blocked (
egress: [{}]): all traffic denied, DNS returns REFUSED, no Envoy. - Filtered (rules with FQDN/CIDR/L7 matchers): per-rule chain isolation with OR semantics, FQDN IP sets with per-element TTLs, Envoy MITM for L7 inspection. All TCP (FQDN and CIDR) is routed through Envoy; CIDR TCP uses a dedicated catch-all listener forwarding via original_dst. UDP is routed through Envoy via TPROXY. SCTP bypasses Envoy (no TPROXY or NAT REDIRECT).
In non-blocked modes, all terrarium traffic passes through Envoy for access logging and policy enforcement. TCP and UDP use different interception mechanisms because of how the kernel recovers original destination addresses.
graph TD
user["User process\n(UID 1000)"]
tcp{"TCP"}
udp{"UDP"}
nat["nftables\nNAT OUTPUT\n(deny CIDR ACCEPT,\nallow CIDR REDIRECT,\nFQDN REDIRECT)"]
filter["nftables\nOUTPUT filter\n(per-rule allow/deny)"]
drop["DROP"]
mangle_out["nftables\nmangle OUTPUT\n(fwmark 0x1)"]
policy["Policy routing\n(table 100, loopback)"]
mangle_pre["nftables\nmangle PREROUTING\n(TPROXY)"]
envoy_fqdn["Envoy per-port listeners\n(:15443 for 443\n:15080 for 80\n:15001 catch-all)"]
envoy_cidr["Envoy CIDR catch-all\n(:15003, original_dst)"]
envoy_udp["Envoy UDP listener\n(:15002)"]
dns["DNS proxy\n(:53)"]
upstream(("Upstream"))
user --> tcp
user --> udp
tcp --> nat
udp -- "port 53" --> dns
udp -- "other ports" --> filter
nat -- "deny CIDR\n(ACCEPT, no redirect)" --> filter
nat -- "CIDR TCP" --> envoy_cidr --> upstream
nat -- "FQDN TCP" --> envoy_fqdn --> upstream
nat -- "non-policy TCP" --> envoy_fqdn
filter -- "denied" --> drop
filter -- "UDP allowed" --> mangle_out
mangle_out --> policy --> mangle_pre --> envoy_udp --> upstream
nftables NAT output rules REDIRECT all terrarium-UID TCP to Envoy. The NAT
chain evaluates rules in order: deny CIDR chains ACCEPT (skip redirect so the
filter chain drops the traffic), allow CIDR chains REDIRECT to the CIDR
catch-all listener (port 15003), then per-port FQDN REDIRECT rules. Specialized
listeners handle ports 443 (TLS passthrough, port 15443) and 80 (HTTP forward,
port 15080). Per-rule port restrictions get dedicated listeners at ProxyPortBase
(15000) + port. Non-policy traffic hits a catch-all TCP proxy on port 15001
that rejects via a blackhole cluster. CIDR-allowed TCP is forwarded by the CIDR
catch-all listener via the original_dst cluster; it also runs a TLS inspector
for SNI visibility in access logs. Envoy uses the original_dst listener
filter to recover the real destination from conntrack (SO_ORIGINAL_DST).
UDP cannot use NAT REDIRECT because SO_ORIGINAL_DST only works for TCP. Instead, nftables mangle chains and Linux policy routing implement TPROXY:
- A mangle output chain marks all terrarium-UID UDP packets (excluding port 53) with fwmark 0x1.
- A policy routing rule directs marked packets to table 100, which has a local default route through loopback.
- The packet re-enters via loopback and hits a mangle prerouting chain that applies TPROXY, delivering it to Envoy's UDP listener on port 15002.
- Envoy binds a transparent socket (IP_TRANSPARENT) at 0.0.0.0:15002, recovering the original destination from the socket address itself.
Port 53 is excluded from TPROXY marking so DNS queries reach the DNS proxy
directly on loopback. Loose reverse-path filtering (rp_filter=2) is required on
loopback because TPROXY'd packets arrive with non-local source addresses. The
firewall package sets net.ipv4.conf.lo.rp_filter and
net.ipv4.conf.all.rp_filter to 2 during policy routing setup, since the
effective value is max(conf.all, conf.<iface>) and both must be loose.
The entire stack is dual-stack. nftables uses a single inet-family table that
covers both IPv4 and IPv6 (replacing four legacy iptables tables). FQDN IP sets
are created in pairs: one TypeIPAddr set for A records and one TypeIP6Addr
set for AAAA records, both with per-element TTLs. The mangle prerouting chain
has separate per-AF TPROXY rules for NFPROTO_IPV4 and NFPROTO_IPV6. Policy
routing rules and routes are installed for both AF_INET and AF_INET6. The
DNS proxy listens on both 127.0.0.1:53 and [::1]:53, and /etc/resolv.conf
is rewritten with both nameservers.
At startup, init checks whether IPv6 is actually available via
net.ipv6.conf.all.disable_ipv6. If the stack appears disabled, IPv6 is
explicitly turned off via sysctl on all interfaces as defense-in-depth. The
ipv6Disabled flag is threaded through to the DNS proxy so it skips binding
[::1].
- Capture upstream DNS from
/etc/resolv.conf - Generate configs (firewall, Envoy, certs) if not pre-baked
- Install CA certificates (if L7 rules need MITM)
- Apply nftables rules atomically via netlink
- Set up policy routing for UDP TPROXY (if egress is not blocked)
- Start DNS proxy, rewrite
/etc/resolv.confto loopback - Start Envoy (if egress is not blocked), wait for listener readiness
- Create ready-file if configured (signals all infrastructure is up)
- Drop privileges, exec user command
- Forward signals to user command, reap zombies
- On exit: drain Envoy, stop DNS proxy, remove policy routes, flush nftables