Kubo on low-memory devices

August 13, 2026 ยท View on GitHub

This guide is for running the Kubo daemon on devices with limited RAM, such as a Raspberry Pi or Orange Pi with 8 GiB, managed by systemd. It covers three layers that work together: the Go runtime, the systemd unit, and Kubo configuration. Set all three. Each layer catches what the previous one cannot. The same principles apply to other orchestration: for example, in Docker set GOMEMLIMIT via --env and replace the systemd limits with --memory and --memory-reservation.

Quick start (8 GiB device)

Add memory limits to the service:

$ sudo systemctl edit ipfs
[Service]
Environment=GOMEMLIMIT=4GiB
MemoryHigh=6G
MemoryMax=7G
Restart=on-failure

Cap concurrent DHT announcement work, and turn off the services that spend resources on other peers:

$ ipfs config --json Provide.DHT.MaxWorkers 4
$ ipfs config Routing.Type autoclient
$ ipfs config AutoNAT.ServiceMode disabled
$ ipfs config --json Swarm.RelayService.Enabled false

Then reload and restart:

$ sudo systemctl daemon-reload
$ sudo systemctl restart ipfs

If the node provides millions of CIDs, also read Announcements on a large pinset below.

How the layers fit together

Keep the values in this order, with room between each pair:

GOMEMLIMIT  <  MemoryHigh  <  MemoryMax  <  total RAM

Go runtime: GOMEMLIMIT

GOMEMLIMIT is a soft ceiling for the memory managed by the Go runtime. As usage approaches the limit, the garbage collector runs more often and returns free memory to the OS sooner. Set it to about half of total RAM.

The limit is soft: memory that is still in use cannot be freed, so the process can exceed GOMEMLIMIT under load. That is why the systemd limits below are still needed. See the Go garbage collector guide for how the limit works.

For a systemd service, set it with Environment=GOMEMLIMIT=4GiB in the unit (or a systemctl edit drop-in, as in the quick start). For manual runs, export it in the shell before starting the daemon.

systemd: MemoryHigh, MemoryMax, swap

MemoryHigh is the throttling threshold: above it, the kernel slows the service down and reclaims memory from it. MemoryMax is the hard limit: above it, the kernel kills the service.

Two rules for picking the values:

  • Leave a gap between GOMEMLIMIT and MemoryHigh. The service uses memory the Go runtime does not manage: thread stacks, and the OS page cache for repo files, which the kernel accounts to the service. If MemoryHigh sits at or below GOMEMLIMIT, the kernel throttles the daemon during normal operation.
  • On a device without swap, keep MemoryMax close above MemoryHigh. Without swap the kernel cannot reclaim the daemon's working memory, so a process stuck above MemoryHigh is throttled to a crawl and can stay there indefinitely: it drops peers and stops responding, but never recovers. Crossing MemoryMax instead gets a clean kill, and Restart=on-failure brings the daemon back within seconds. A fast restart beats a slow zombie.

If the device has swap, add MemorySwapMax=0 to keep the daemon from pushing the rest of the system into swap.

Kubo configuration

Run the node as a DHT client. With the default Routing.Type of auto, a publicly reachable node becomes a DHT server: it stores other peers' provider records in its datastore and answers their lookups, which adds network, CPU, and storage load unrelated to your own content. Setting Routing.Type to autoclient (or dhtclient) removes that duty; the node still announces its own content and retrieves normally.

Two more services exist to help other peers and can go on constrained hardware: AutoNAT.ServiceMode set to disabled stops answering other peers' reachability dial-back requests, and Swarm.RelayService.Enabled set to false stops relaying traffic between third parties. Neither affects this node's own connectivity: it still uses AutoNAT as a client and public relays when it needs them.

On a node that announces a lot of content, the DHT announcement subsystem is the main memory consumer that Kubo configuration controls. Provide.DHT.MaxWorkers caps its concurrency; the next section explains why this matters on low-memory devices.

Leave Swarm.ResourceMgr.MaxMemory unset unless you know what you are doing. It does not limit the Kubo process; it scales the libp2p resource manager limits, including how many inbound connections the node accepts. Setting it too low cripples connectivity: the daemon keeps running and looks online, but the resource manager refuses new connections with cannot reserve inbound connection: resource limit exceeded errors, logged as "Protected from exceeding resource limits". Inspect current usage against the limits with ipfs swarm resources.

Announcements on a large pinset

Keep Provide.DHT.SweepEnabled at its default true on low-power devices. Sweep mode spreads announcement work smoothly across the reprovide cycle instead of completing it in bursts, which avoids the resource spikes of the legacy provider.

With sweep mode, the daemon announces content region by region across the DHT keyspace, and each active worker holds one region's keys in memory while it works. After downtime, many regions are due at once and the daemon puts every worker to work, so peak memory scales with Provide.DHT.MaxWorkers times the region size. On a node providing millions of CIDs, a high worker count can consume several GiB during this catch-up phase; 4 workers keep the peak small on an 8 GiB device.

Size the workload with two rates. The sustained rate is what your node actually announces: read Total CIDs provided from ipfs stats provide twice, 24 hours apart. As a reference, an 8 GiB board with NVMe storage and 4 workers sustains roughly 300k records per hour. The required rate is your CID count divided by Provide.DHT.Interval (default 22h). Keep the required rate below the sustained rate, with margin. The hard floor is record expiry: provider records on the Amino DHT expire after 48 hours (amino.DefaultProvideValidity), so a full pass over the pinset must always complete faster than that, or some content periodically becomes undiscoverable.

Worked example, 10 million CIDs on an 8 GiB device: the default 22h interval requires ~455k records per hour, above the reference rate, so the cycle slips. Raising Provide.DHT.MaxWorkers to 6 and Provide.DHT.Interval to 32h lowers the requirement to ~313k per hour, within reach of the sustained rate, and a full pass stays well inside the 48h expiry (the floor for 10M CIDs is ~208k per hour). After a change, let a full cycle finish and use the Verify checks to confirm the reprovide queue is not growing.

Do not enable Routing.AcceleratedDHTClient to speed announcements up on a low-memory device. It crawls the entire DHT on an hourly schedule, and those crawls cause the memory and traffic spikes this guide exists to prevent. Adjust workers and interval as above instead.

The number of announced CIDs itself is the biggest lever. Provide.Strategy selects what gets announced: pinned+mfs+entities announces file and directory roots instead of every block, which typically shrinks the workload by orders of magnitude. Read the trade-offs in the strategy documentation before changing it.

Verify

Check where the service sits relative to its limits:

$ systemctl status ipfs | grep Memory

Check memory pressure. The avg10 values show the percentage of time the service was stalled waiting for memory over the last 10 seconds. Near-zero is healthy; sustained values above a few percent mean the limits are too tight or the workload is too big:

$ cat /sys/fs/cgroup/system.slice/ipfs.service/memory.pressure

Check that announcements keep up with the reprovide cycle:

$ ipfs stats provide

See provide-stats.md for reading this output, and libp2p resource management for the resource manager's own limits.