The “I want my Go binary to bind port 80 without running as root” problem has three solutions of varying terribleness:
- Run the whole thing as root. The traditional answer. Catastrophic if the binary has a single bug — game over for your box.
- Stick a reverse proxy in front (nginx/Caddy/etc) bound on 80, app on 8080. Works, adds a hop, complicates ops.
- Give the binary the specific capability it needs. The right answer in 2026, and it’s one command.
This post is about option 3 — Linux capabilities — what they are, the one-liner that solves the privileged-port problem, and the four caveats that bite people on first contact.
What capabilities are
Historically, the kernel split privileges into two: root (UID 0) can do anything; everyone else can do almost nothing. This is too coarse. “I need to bind port 80” doesn’t require “I can also wipe the disk.”
Linux capabilities are a finer-grained partition of root’s powers. There are around 40 of them — CAP_NET_BIND_SERVICE, CAP_NET_RAW, CAP_SYS_TIME, CAP_DAC_OVERRIDE, etc. Each one represents a specific kernel privilege. You can grant them individually to a binary, file by file.
The capabilities you’ll actually use as a sysadmin:
cap_net_bind_service— bind to ports below 1024.cap_net_raw— open raw sockets (ping, traceroute, custom protocols).cap_sys_time— set the system clock.cap_dac_read_search— bypass file-read permission checks (for log readers and similar).
The one-liner
# Grant CAP_NET_BIND_SERVICE to a single binary
sudo setcap 'cap_net_bind_service=+ep' /usr/local/bin/myapp
# Verify
getcap /usr/local/bin/myapp
# /usr/local/bin/myapp = cap_net_bind_service+ep
Now myapp can bind to port 80 even when run as a non-root user. No reverse proxy, no setuid bit, no wrapper script.
The +ep means “Effective + Permitted” — the binary has the capability available (Permitted) and starts with it active (Effective). For most cases, this is what you want. +i would add Inheritable (capability passes to child processes) which is rarely needed and creates more attack surface.
Caveat 1: capabilities are wiped on file write
Capabilities are stored as an extended attribute on the file (security.capability). Any operation that rewrites the file — package upgrade, rsync, cp without --preserve=xattr, deploy script that mvs a new build over the old — strips them. The binary continues to exist; the capability silently disappears.
Mitigation: re-apply setcap in your deploy hook, or use a systemd unit that calls it at ExecStartPre. The cleaner approach for systemd-managed services is to use AmbientCapabilities instead — see below.
Caveat 2: filesystem support
Capabilities are extended attributes; not all filesystems support them. ext4, xfs, btrfs all do. Some FUSE mounts and old NFS implementations don’t. If setcap seems to succeed but getcap shows nothing, you’re probably on a filesystem without xattr support.
Test with getfattr -d binary. If you don’t see security.capability, the FS isn’t holding it.
Caveat 3: scripts can’t have capabilities
setcap only works on binaries — ELF files. You can’t setcap a bash script or Python script. The kernel doesn’t grant capabilities to interpreted code; it would have to grant them to /bin/bash, which would mean every bash script on the system inherits them. Hard no.
If you need a Python service to bind port 80, the options are:
- Run via a small C wrapper that has the capability and execs Python. Fragile.
- Use systemd’s
AmbientCapabilities=CAP_NET_BIND_SERVICEin the unit file. The kernel grants the capability to the systemd-spawned process, regardless of whether it’s an interpreted script.
The systemd approach is much cleaner:
# /etc/systemd/system/myapp.service
[Service]
User=myapp
Group=myapp
ExecStart=/usr/bin/python3 /opt/myapp/server.py
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
CapabilityBoundingSet caps what the process can ever request — drops everything except the one capability you grant. NoNewPrivileges prevents execve from picking up new privileges. Together: the process has exactly one super-power, and even if it spawns child processes, they can’t gain anything else.
Caveat 4: capabilities don’t compose with setuid
If you set both the setuid bit and a capability on a binary, the kernel applies the setuid first (process becomes root), at which point all capabilities are implicit. The capability set you specified is irrelevant.
Choose: setuid or capability. Don’t try to mix. For new code, capability is always the right answer; setuid is a legacy mechanism with a much wider attack surface.
The audit recipe
To find every binary on the system that has capabilities granted (useful for security review):
# Walk the entire filesystem looking for binaries with caps
sudo getcap -r / 2>/dev/null
# /usr/bin/ping = cap_net_raw+p
# /usr/sbin/arping = cap_net_raw+p
# /usr/bin/traceroute = cap_net_raw+p
# /usr/local/bin/myapp = cap_net_bind_service+ep
Most distros ship a small handful of capability-granted binaries (ping, mtr, traceroute, ufw helpers). Anything else on the list is yours — review periodically.
Why this matters
The principle of least privilege is one of those things everyone agrees with in theory and nobody implements in practice — usually because the alternative (“just run it as root”) is so much easier. Linux capabilities make least-privilege actually achievable for the common cases. Two minutes of work, one fewer “compromised app means compromised box” failure mode.
Next time you’re tempted to run a network daemon as root, run it as myapp with CAP_NET_BIND_SERVICE instead. The post-incident gratitude is worth the upfront effort.
Cover photo: Real Tough Candy on Pexels.
