Your Go binary can tell the kernel exactly what it needs — and the kernel will deny everything else. No containers. No root. No daemon. Five lines of code.

A container is not a security boundary. It never was. Your binary runs inside a namespace, and the namespace gives it full access to every file, every port, and every mounted secret it can see. Get code execution inside a container and you own everything the container owns. The image tag said hardened. The Dockerfile didn't.

A single binary that restricts itself is more secure than a containerised binary that doesn't. Landlock is how you do it.

Landlock is a Linux Security Module, available since kernel 5.13, that lets any process — including unprivileged ones — restrict its own access to the filesystem and network. The restrictions are enforced by the kernel, survive exec, inherit to child processes, and can never be loosened once applied. For Go developers shipping single binaries to Linux servers, it's the missing primitive between "deployed" and "hardened."

The Mental Model

Forget firewall rules. Forget policy files. Landlock is closer to pledge() on OpenBSD: your process voluntarily surrenders capabilities it doesn't need. The difference is that instead of declaring broad categories ("I need network" / "I need filesystem"), you declare specific resources: these directories, these ports, these operations. Everything else is dead to you.

It's a deny-by-default allow-list. You don't enumerate what to block — you'd never finish. You enumerate what you need, and the kernel kills everything else.

Once enforced, the sandbox only gets tighter. A sandboxed process can add more restrictions but never remove existing ones. No syscall, no capability, no amount of privilege escalation undoes it. The kernel is the enforcer, not a userspace daemon you can kill.

Under the hood, the kernel exposes three syscalls — landlock_create_ruleset, landlock_add_rule, landlock_restrict_self — but you don't need to touch them. The Go library abstracts this into a single function call.

ABI Versioning

Landlock's capabilities are tied to kernel versions. Your code queries the running kernel's ABI version at runtime and adapts. This table is the reference you'll actually use:

ABIKernelWhat It Added
15.13Core filesystem control — 13 rights covering execute, read, write, mkdir, symlink, device creation
25.19File reparenting (FS_REFER) — control over rename/link across directories
36.2Truncation control (FS_TRUNCATE)
46.7Network access control — TCP bind and connect restrictions
56.10Device ioctl filtering (FS_IOCTL_DEV)
66.12IPC scoping — abstract Unix sockets and signal restrictions

This matters because your binary might run on kernel 5.15 (Ubuntu 22.04 LTS) or kernel 6.12 (current Fedora). The Go library handles degradation for you — more on that in a moment.

The Library

The official Go binding is github.com/landlock-lsm/go-landlock, maintained by the Landlock kernel developers themselves. MIT-licensed. Minimal dependencies. The maintainers have explicitly committed to keeping it at an auditable size. That last detail matters: a security library you can't read is a liability pretending to be an asset.

go get github.com/landlock-lsm/go-landlock/landlock

The Simplest Useful Example

Your service reads config from /etc/myapp/ and writes logs to /var/log/myapp/. It has no business touching anything else on the filesystem:

package main

import (
    "fmt"
    "log"
    "os"

    "github.com/landlock-lsm/go-landlock/landlock"
)

func main() {
    err := landlock.V5.BestEffort().RestrictPaths(
        landlock.RODirs("/etc/myapp"),
        landlock.RWDirs("/var/log/myapp"),
        landlock.RODirs("/usr", "/lib", "/lib64"),
    )
    if err != nil {
        log.Fatalf("landlock: %v", err)
    }

    // Works — we allowed it.
    data, err := os.ReadFile("/etc/myapp/config.yaml")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Config loaded: %d bytes\n", len(data))

    // Denied — we never mentioned /etc/passwd.
    _, err = os.ReadFile("/etc/passwd")
    fmt.Printf("Blocked: %v\n", err)
    // Output: Blocked: open /etc/passwd: permission denied
}

That's it. Five lines of setup and the kernel enforces it for the lifetime of the process.

Strict vs BestEffort

landlock.V5.BestEffort() asks the kernel for its ABI version. If it supports V5, you get V5. If it only supports V3, you get V3 — fewer restrictions are available, but those that are still apply. If Landlock isn't available at all, the call succeeds silently and nothing is restricted.

For most applications, this is the right default. If you require Landlock — if running without it is a security failure — drop .BestEffort() and call landlock.V5.RestrictPaths() directly. It will return an error on unsupported kernels, and you should treat that as fatal.

The Production Pattern

A toy example proves the API works. A production pattern proves it's practical. The key insight is phased privilege dropping: do your initialisation work unrestricted, then enforce the sandbox before you start serving.

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"

    "github.com/landlock-lsm/go-landlock/landlock"
    _ "github.com/lib/pq"
)

func main() {
    // ── Phase 1: Init (unrestricted) ──
    cfg := mustLoadConfig("/etc/myapp/config.yaml")
    db := mustConnectDB(cfg.DatabaseURL)
    defer db.Close()

    mux := buildRoutes(db)
    srv := &http.Server{Addr: ":8080", Handler: mux}

    // ── Phase 2: Drop privileges ──
    if err := enforceSandbox(); err != nil {
        log.Fatalf("sandbox: %v", err)
    }
    log.Println("landlock: sandbox enforced")

    // ── Phase 3: Serve (restricted) ──
    go func() {
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatalf("http: %v", err)
        }
    }()

    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
    defer stop()
    <-ctx.Done()
    srv.Shutdown(context.Background())
}

func enforceSandbox() error {
    // V6 targets the latest ABI — includes network (V4+) and
    // IPC scoping (V6). BestEffort degrades on older kernels.
    return landlock.V6.BestEffort().Restrict(
        // Filesystem: read-only for shared libs, certs, tzdata
        landlock.RODirs("/usr", "/lib", "/lib64"),
        landlock.RODirs("/etc/ssl/certs", "/etc/pki"),
        landlock.RODirs("/usr/share/zoneinfo"),

        // Filesystem: read-write for logs only
        landlock.RWDirs("/var/log/myapp"),

        // Network: HTTP server + downstream deps
        landlock.BindTCP(8080),
        landlock.ConnectTCP(5432),  // Postgres — pool may reconnect
        landlock.ConnectTCP(6379),  // Redis
        landlock.ConnectTCP(53),    // DNS over TCP
    )
}

Notice what's happening. Config files are read before the sandbox drops — we don't need to allow runtime access to them. The database connection is established before sandboxing, but we still allow port 5432 because connection pools reconnect. The Restrict method applies filesystem and network rules in a single atomic operation.

If an attacker gets code execution inside this process after Phase 2, they can read /usr and write to /var/log/myapp. They can connect to Postgres and Redis. They cannot read /etc/shadow. They cannot open a reverse shell to an arbitrary host. They cannot bind a new listener on a port you didn't specify. The kernel denies it. There is no workaround.

DNS: The Trap You'll Hit

If you restrict network access, DNS resolution over TCP needs port 53. Go's default pure-Go resolver uses UDP, which Landlock doesn't yet restrict — so it works without any network rules. But if your build uses CGO (check with go env CGO_ENABLED), the system resolver may need TCP, and you'll also need read access to /etc/resolv.conf and /etc/nsswitch.conf. This will burn an hour of your life the first time. Now it won't.

Debugging Denials

When Landlock denies an access, your process sees a standard EACCES — permission denied. There's no Landlock-specific error to catch. If your sandboxed service starts failing and you need to know what was denied, the kernel's audit subsystem is your friend. On recent kernels, Landlock generates AUDIT_LANDLOCK_ACCESS records that identify the denied operation, the file path or port, and the domain that blocked it. Check with ausearch -m LANDLOCK_ACCESS or journalctl -k -g landlock. Without this, you're debugging permission errors with strace and guesswork — possible, but slower than it needs to be.

What Landlock Isn't

Containers provide isolation through namespaces — separate filesystem trees, network stacks, PID spaces. That's operational packaging, not security. A container without internal restrictions is a locked room where the intruder has full access to everything inside. Landlock provides restriction within a process: it doesn't give you a separate filesystem, it tells the kernel which parts of the real one your process is allowed to touch. The two compose well. A containerised service that also self-sandboxes with Landlock has genuine defence-in-depth. A container on its own has a door.

seccomp-bpf, SELinux, and AppArmor are the rest of the Linux security stack, and they all share a property Landlock doesn't: someone else has to configure them. seccomp restricts which syscalls a process can make (a different axis from Landlock, which restricts which resources those syscalls can reach — a proper posture uses both). SELinux and AppArmor are system-wide MAC frameworks that require root, policy files, and a sysadmin who speaks the language and is willing to maintain the policies across upgrades. Landlock is self-imposed by the process, requires no privileges, and ships inside the binary. You don't file a ticket to harden your own code.

Goroutines and Threads

Go multiplexes goroutines across OS threads. Landlock is enforced per-thread. The go-landlock library solves this with an AllThreads variant of the landlock_restrict_self syscall, applying the ruleset to every OS thread in the process simultaneously. This means Landlock in Go is all-or-nothing at the process level — you can't sandbox individual goroutines. In practice that's what you want: a compromised goroutine shouldn't have different privileges than the rest of your process. There's an open proposal (#70993) for per-goroutine sandboxing via runtime.LockOSThreadRecursive, but it hasn't landed.

landrun: The Outside-In Version

Everything above assumes you control the source. When you don't — when you're running someone else's binary and want to restrict it — landrun applies Landlock from the outside. It's a Go CLI that wraps any command in a Landlock policy. It launched on Hacker News in March 2025 and became the most popular Landlock wrapper tool inside a week.

# Build with restricted filesystem access
landrun \
  --allow-read /usr \
  --allow-read /home/alan/project \
  --allow-write /home/alan/project/build \
  --allow-read-write /tmp \
  -- go build -o ./build/server ./cmd/server

# Run an AI coding agent on a leash
landrun \
  --allow-read /home/alan/project \
  --allow-write /home/alan/project \
  --allow-connect-tcp 443 \
  -- claude-code

That second example is increasingly relevant. AI coding agents want filesystem access and network access. Landlock lets you give them exactly the project directory and nothing else. No ~/.ssh. No ~/.aws. No ~/.config. The kernel enforces it — not a promise from the agent vendor, not a config file you hope they respect.

This is useful, but it's not a substitute for a binary that knows its own boundaries. An external wrapper can only restrict what it can see from the outside — it can't distinguish between your service reading config during init and reading config at runtime. The phased privilege model from the previous section is strictly more precise. If you have the source, sandbox from the inside. If you don't, landrun is the next best thing.

It costs almost nothing. A few lines in your main(). No runtime overhead once enforced. No external dependencies beyond a single, auditable library maintained by the people who wrote the kernel feature. Five lines of Go is the distance between "deployed" and "hardened."