Guides Hardening

Hardening cloudflared with systemd: a dedicated user and a tight sandbox

Run your tunnel connector as an unprivileged user with locked-down credentials and a systemd sandbox, keep it patched through apt, and score the result.

By Micheal Garner. Published 1 November 2026. 5 minute read.

cloudflared is a network daemon that holds a credential able to speak for your tunnel and that parses data from the internet. That combination is the profile of something worth sandboxing. The good news is that it does very little: it opens outbound connections, reads one config file and one credentials file, and forwards bytes. That makes it an easy process to confine.

This guide runs it as a dedicated unprivileged user, restricts what the process can do with systemd, and sets up updates so it does not become stale. It assumes a working locally-managed tunnel with its config in /etc/cloudflared.

Why bother

If a flaw were ever found in cloudflared, or in anything it connects to, the damage an attacker can do is bounded by what the process may do. A process running as root with full filesystem access can do a great deal. One running as a throwaway user with no home directory, no ability to write anywhere and no ability to gain new privileges can do very little. Hardening costs a few minutes and shrinks that blast radius, which is the cheapest security improvement on offer.

Step 1: a dedicated user

sudo useradd --system --no-create-home --shell /usr/sbin/nologin cloudflared

A system user cannot log in, has no home directory and exists only to own the process.

Step 2: lock down the files

The credentials file lets whoever holds it run your tunnel. It should be readable by the service account and no one else:

sudo chown root:cloudflared /etc/cloudflared/config.yml /etc/cloudflared/6ff42ae2-765d-4adf-8112-31c55c1551ef.json
sudo chmod 0640 /etc/cloudflared/config.yml /etc/cloudflared/6ff42ae2-765d-4adf-8112-31c55c1551ef.json
sudo ls -l /etc/cloudflared

Root owns the files and the cloudflared group can read them, so the daemon can use its config but cannot modify it. If it were compromised, it could not rewrite its own routes. Replace the UUID with the one in your config.

Step 3: the unit file

Create /etc/systemd/system/cloudflared.service:

[Unit]
Description=cloudflared tunnel (homelab)
After=network-online.target
Wants=network-online.target

[Service]
Type=notify
User=cloudflared
Group=cloudflared
ExecStart=/usr/bin/cloudflared --no-autoupdate tunnel --config /etc/cloudflared/config.yml run
Restart=on-failure
RestartSec=5s
TimeoutStartSec=15

NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
CapabilityBoundingSet=
AmbientCapabilities=

[Install]
WantedBy=multi-user.target

You can generate this file, with your tunnel’s name filled in, from the config generator.

What the important lines do:

  • Type=notify lets cloudflared tell systemd when it is actually connected, so systemctl start waits for a working tunnel rather than a started process.
  • --no-autoupdate stops the binary trying to replace itself. When the package manager owns the binary, the package manager should do the updating.
  • NoNewPrivileges=true stops the process, and anything it launches, from gaining privileges through set-uid programs.
  • ProtectSystem=strict mounts the whole filesystem read-only for this process, apart from a few device and temporary locations. It cannot write to /etc, /usr or anywhere else.
  • ProtectHome=true hides home directories entirely, which is why the credentials live in /etc/cloudflared and not under a user’s home.
  • RestrictAddressFamilies limits it to IPv4, IPv6 and local sockets. It has no use for raw packet or netlink sockets, so it does not get them.
  • CapabilityBoundingSet= with nothing after it drops every Linux capability. Binding to ports below 1024 is not something an outbound-only connector needs.
  • MemoryDenyWriteExecute=true refuses memory that is both writable and executable, which blocks a common exploitation technique. Go programs generally run fine under it. If your build does not, remove that line and note it.

Load it, start it and check:

sudo systemctl daemon-reload
sudo systemctl enable --now cloudflared
sudo systemctl status cloudflared --no-pager
sudo journalctl -u cloudflared -n 30 --no-pager

If you previously ran cloudflared service install, a unit with the same name may already exist. The file in /etc/systemd/system takes precedence over one supplied elsewhere, so this replaces it. Run systemctl cat cloudflared to confirm which one is active.

Step 4: score it

systemd can grade how exposed a service is:

systemd-analyze security cloudflared

It lists each setting with a weight and gives an overall exposure score, where lower is better. The stock unit scores poorly and a sandbox like the one above should score far better. The tool is blunt, since it also marks you down for things your service has no need for, so use it to spot settings you missed rather than chasing a perfect number.

Step 5: keep it patched

Install from Cloudflare’s apt repository, as in the first guide, and updating is two commands:

sudo apt-get update
sudo apt-get install --only-upgrade cloudflared
sudo systemctl restart cloudflared

The restart matters. Upgrading the package replaces the binary on disk, but the running process keeps executing the old one until it restarts. Add a reminder, or use unattended upgrades with a hook that restarts the service. If you run several tunnels, check them all, since each connector is updated separately.

Subscribe to Cloudflare’s announcements for cloudflared. A connector that has not been updated in a year is the sort of thing that turns up in an audit.

Optional: raise the UDP buffers

You may see a log line saying cloudflared failed to sufficiently increase its receive buffer size. It is generally harmless, and Cloudflare’s guidance is to ignore it unless you need higher throughput. If you do, raise the kernel limits:

sudo tee /etc/sysctl.d/90-cloudflared-quic.conf >/dev/null <<'EOF'
net.core.rmem_max=7500000
net.core.wmem_max=7500000
EOF
sudo sysctl --system
sudo systemctl restart cloudflared

A short checklist

  • The service runs as an unprivileged user, not root.
  • Credentials are mode 0640, owned by root, and readable only by that user’s group.
  • The unit sandbox is in place and systemd-analyze security has been run.
  • Updates come from the package manager, and the service is restarted after each one.
  • Outbound port 7844 is open to Cloudflare for UDP and TCP, and nothing inbound is open.
  • Every published hostname has an Access policy or a deliberate reason not to.

Sources: Cloudflare’s guidance on running a tunnel as a service and tunnel troubleshooting, and the systemd documentation for systemd.exec and systemd-analyze security.