Linux capabilities for a single binary: setcap cap_net_bind_service= /usr/local/bin/myapp without running as root

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_SERVICE in 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.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.