⏱️ Reading time: 14 min

A $10 ARM gadget with only 512 MB of RAM shows, in practice, the limits and possibilities of low-RAM self-hosting. Swiss developer Bosscher installed Debian, systemd, and Podman on an OpenStick and ended up running six real services within the 450 MB left free after the system.

📑 En este artículo
  1. TL;DR
  2. What Is Low-RAM Self-Hosting?
  3. Why Running Services on Such Limited Hardware Matters
  4. How the Stack Works: Debian, systemd, and Podman
  5. Practical Examples and How to Get Started
  6. Real Use Cases That Fit in 450 MB
  7. Common Mistakes and Best Practices
  8. Comparison with Lightweight Monitoring Alternatives
  9. Going Deeper: cgroups, Overcommit, and Why Container Count Matters
  10. Frequently Asked Questions
    1. Does Podman Fully Replace Docker on a Low-RAM Home Server?
    2. How Much RAM Does Debian Minimal Need Before Running Any Service?
    3. Why Use Beszel Instead of Prometheus and Grafana on a 512 MB Device?
    4. What Happens If a Podman Container Exceeds the Memory Limit I Assigned?
    5. Does This Low-RAM Self-Hosting Approach Work on a Raspberry Pi Zero?
    6. Do You Need Swap for This Stack to Be Stable?
  11. References

That tight budget works as a case study for any cheap ARM board: a Raspberry Pi Zero, a reflashed router, or the cheapest VM from a cloud provider. The question isn’t whether it runs Linux, but which combination of services fits without the kernel starting to kill processes for lack of memory.

TL;DR

  • With 450 MB of usable RAM, an ARM Cortex-A53 runs 4 to 6 lightweight Podman containers at once without swap.
  • The Next-Gen fork of AdGuard Home blocks DNS with about 40 MB fixed, regardless of the blocklist size.
  • Beszel monitors CPU and memory with 20 MB total: 10 MB for the agent and 10 MB for the server.
  • Rootless Podman adds per-container overhead via slirp4netns, so the number of services matters more than the size of each image.
  • systemd-oomd and zram swap keep a memory spike from taking down the whole stack on such tight hardware.

What Is Low-RAM Self-Hosting?

Low-RAM self-hosting is the practice of running your own services (DNS, monitoring, RSS, tunnels) on hardware with very little available memory, typically under 512 MB, using a lightweight operating system and lightweight containers instead of a full Kubernetes-style stack.

Compared to a conventional homelab, the real constraint is the memory budget. Every service competes for the same megabytes, and there’s no room for processes that reserve extra memory just “in case.” The OpenStick, with its four ARM Cortex-A53 cores at 1 GHz and 4 GB of eMMC storage, is an extreme example, but the same reasoning applies to any Raspberry Pi Zero 2 W, a router running OpenWrt, or the cheapest VM from a cloud provider.

In that context, Debian provides a stable base system with battle-tested packages, systemd handles starting and watching each service, and Podman replaces Docker as the container engine without a daemon running in the background all the time.

Why Running Services on Such Limited Hardware Matters

Most self-hosting tutorials assume a machine with 4 or 8 GB of RAM, which is plenty to run half a dozen containers without thinking about the real cost of each one. That comfort hides a problem. When you migrate to something cheaper (a budget VM, an embedded device, or a recycled server), the same containers that “worked fine” start restarting on their own.

Understanding what consumes memory and why (the container engine, each service’s network proxy, journald’s buffers) lets you port a home server stack to much cheaper hardware, or fit more services on the same machine without upgrading your plan. The OpenStick case shows this with concrete numbers: with just 450 MB usable, six full services run.

How the Stack Works: Debian, systemd, and Podman

Debian minimal, the “netinst” install without a graphical environment, uses between 80 and 120 MB of RAM at idle depending on which daemons run alongside the kernel. That baseline consumption is the first fixed cost of low-RAM self-hosting: before you spin up a single container, the system has already claimed a non-trivial share of the available total.

systemd manages the startup and lifecycle of each service through .service units. Since version 243 it includes systemd-oomd, a memory watchdog that acts based on cgroups v2 pressure, before the kernel’s OOM killer blindly kills processes.

Podman, unlike Docker, doesn’t need a daemon running in the background: each container starts as a child process of conmon, supervised directly by systemd. In rootless mode, the recommended setup for any device exposed to the internet, each container with its own network uses slirp4netns for user-mode NAT. That adds between 5 and 10 MB of RAM per container just in network overhead.

Podman runs without a persistent daemon: each container is a child process of conmon. Foto de Brett Jordan en Unsplash
flowchart TD
    A["Debian minimal"] --> B["systemd"]
    B --> C["Podman rootless"]
    C --> D["conmon + container 1"]
    C --> E["conmon + container 2"]
    C --> F["conmon + container N"]
    B --> G["systemd-oomd"]
    G -. "kills under pressure" .-> D
    G -. "kills under pressure" .-> E

Practical Examples and How to Get Started

Before installing anything you need Debian 12 (bookworm) or newer, root or sudo access, and at least 2 GB of free storage for container images. These commands are tested for Linux (Debian or Raspberry Pi OS). On macOS or Windows, the equivalent path is installing Podman Desktop, which runs a lightweight Linux VM in the background.

sudo apt update
sudo apt install -y podman
podman --version

That last command confirms the installation. The expected output is similar to:

podman version 4.9.3

The first container is just to confirm that the whole stack (kernel, cgroups, conmon) works:

podman run --rm quay.io/podman/hello

The expected output is the project’s official ASCII art:

!... Hello Podman World ...!

Project:   https://github.com/containers/podman
Website:   https://podman.io

With that confirmed, the next step is a real service with an explicit memory limit. AdGuard Home is a good first case because its consumption is predictable: it hovers around 40 MB with a small blocklist, as documented in the OpenStick case.

podman run -d --name adguardhome \
  --restart=always \
  --memory=60m \
  -p 53:53/tcp -p 53:53/udp \
  -p 3000:3000/tcp \
  docker.io/adguard/adguardhome:latest

The --memory=60m flag leaves a 20 MB margin over the expected usage, because the initial setup process loads blocklists into memory before applying them. To confirm the container respects that limit:

podman stats --no-stream adguardhome

The MEM USAGE / LIMIT column should show something close to 38MiB / 60MiB once the service has finished loading.

For the container to survive a device reboot without relying on --restart=always, the recommended approach is to generate a systemd unit with a Quadlet file:

mkdir -p ~/.config/containers/systemd
cat > ~/.config/containers/systemd/adguardhome.container <<'EOF'
[Container]
Image=docker.io/adguard/adguardhome:latest
PublishPort=53:53/tcp
PublishPort=53:53/udp
PublishPort=3000:3000/tcp
Memory=60m

[Install]
WantedBy=default.target
EOF
systemctl --user daemon-reload
systemctl --user start adguardhome.service

To verify that systemd, not a manual restart, controls the container:

systemctl --user is-active adguardhome.service

The expected output is active. If it returns failed, journalctl --user -u adguardhome.service shows the reason, almost always a port already in use or insufficient memory.

Rootless Podman can’t listen on ports below 1024, like DNS’s port 53, without an extra tweak, because those ports stay reserved for privileged processes by kernel design. The fix isn’t running the container as root: it’s lowering the unprivileged port range with sysctl, which delegates those specific ports to normal users without opening the rest.

echo 'net.ipv4.ip_unprivileged_port_start=53' | sudo tee /etc/sysctl.d/99-rootless-dns.conf
sudo sysctl --system
sysctl net.ipv4.ip_unprivileged_port_start

The expected output of that last command is net.ipv4.ip_unprivileged_port_start = 53.

Real Use Cases That Fit in 450 MB

The OpenStick case serves as a reference budget. With 450 MB usable after the system, these are the services documented in the original article and their memory cost:

  • AdGuard Home: network-level DNS blocking, around 40 MB with a small blocklist (200k entries).
  • Beszel: CPU, RAM, and disk monitoring with graphs, 20 MB total.
  • Gatus: HTTP service health checks with alerts, about 10 MB.
  • CastSponsorSkip: skips sponsorships on Chromecast devices, between 5 and 10 MB.
  • Cloudflare Tunnel: exposes services to the internet without opening ports, between 8 and 20 MB depending on traffic.
  • Fusion RSS: self-hosted feed reader, 22 MB.

Adding up the six services, consumption lands around 125 to 170 MB, well below the 450 MB available. The remaining margin isn’t accidental: it’s deliberately left free to absorb spikes, like a blocklist updating or a container restarting, without systemd-oomd starting to kill processes.

What doesn’t fit in that budget matters just as much. Nextcloud, with its PHP stack and MySQL database, already needs between 300 and 500 MB just to start with no active users, leaving the device no margin for anything else. Immich, built for photo libraries with facial recognition, needs a GPU or several gigabytes of RAM for its machine learning model, far beyond what a Cortex-A53 can offer. The practical rule is simple: if a service needs a full relational database or its own machine learning model, it probably doesn’t fit in 450 MB.

Six lightweight services fit in less than 170 MB of the device’s 450 MB usable. Foto de Trnava University en Unsplash

Common Mistakes and Best Practices

The most common mistake is running Docker instead of Podman just because its syntax is already familiar. The Docker daemon alone, with no containers, already consumes between 30 and 60 MB at idle. On a machine with 450 MB usable, that fixed cost is equivalent to losing one of the six services from the list above before installing anything.

Another common mistake is not setting explicit memory limits per container. Without --memory, a container with a leak or a traffic spike can consume all the available RAM and drag the rest of the system down with it. Setting a limit per service, even a generous one, turns an isolated failure into a contained problem.

The 4 GB eMMC is also a scarce resource that’s easy to forget. By default, journald keeps persistent logs on disk with no size limit. On a device with such small flash storage, it’s worth limiting it to volatile memory:

sudo mkdir -p /etc/systemd/journald.conf.d
sudo tee /etc/systemd/journald.conf.d/volatile.conf <<'EOF'
[Journal]
Storage=volatile
RuntimeMaxUse=20M
EOF
sudo systemctl restart systemd-journald

Finally, accumulating container images without cleaning up also exhausts the eMMC fast. Running podman image prune -a after each update avoids running out of space for logs or SQLite databases.

⚠️ Watch out: the cheap eMMC on these devices has a limited number of write cycles. Moving journald and any lightweight database to volatile memory extends the storage’s lifespan, at the cost of losing that data on every reboot.

Comparison with Lightweight Monitoring Alternatives

For observability on a low-memory device, not all options cost the same. This table compares the three most common routes:

OptionWhen to Use ItAdvantageLimitation
BeszelYou want CPU, RAM, and disk graphs with alerts, without the hassle20 MB total, interface ready in minutesFewer alert protocols than a full stack
Prometheus + GrafanaYou already have several servers and need long-term historyDe facto standard, thousands of ready-made dashboardsPrometheus alone already needs 100 to 200 MB at idle
DozzleYou just want to see Podman container logs liveMore convenient interface than the CLI for logsDoesn’t provide CPU or RAM metrics like Beszel

On a device with 450 MB usable, Prometheus and Grafana together already consume between a quarter and a third of the total budget before monitoring anything. Beszel or Dozzle, on the other hand, leave room for the services that actually matter.

Going Deeper: cgroups, Overcommit, and Why Container Count Matters

Linux doesn’t reserve physical memory for a process until it actually writes to it. That’s called overcommit, and it explains why a container can start with --memory=200m on a machine with only 450 MB total, but also why two “lightweight” containers can bring down the system if both hit their limit at the same time.

By default, vm.overcommit_memory is set to 0: the kernel allows memory reservations larger than the physical memory available as long as they seem reasonable, and only fails if actual demand exceeds the limit. To confirm the active mode:

cat /proc/sys/vm/overcommit_memory

The expected output on a default Debian install is 0. Changing it to 2 (strict accounting) on a 512 MB device is usually counterproductive: with so little free memory, even the system’s own processes fail to reserve the margin they request as a precaution.

cgroups v2, the mechanism Podman uses to apply memory limits, exposes the real state of each container in /sys/fs/cgroup. To see how much memory a specific container uses without going through podman stats:

cat /sys/fs/cgroup/user.slice/user-1000.slice/[email protected]/app.slice/*adguardhome*/memory.current

That number, in bytes, is the metric systemd-oomd watches to decide who to kill first when memory pressure rises. The default policy prioritizes the process that most exceeds its own relative limit, not the one using the most absolute memory.

The real reason container count matters more than each image’s size is the fixed overhead per container. Each one adds a conmon process of 2 to 4 MB, and in rootless mode with its own network, a slirp4netns instance adding another 5 to 10 MB. Ten containers of 5 MB each end up weighing almost double those nominal 50 MB just from infrastructure overhead.

sequenceDiagram
    participant U as User
    participant S as systemd
    participant P as Podman
    participant C as Container
    U->>S: systemctl start adguardhome.service
    S->>P: starts conmon and container
    P->>C: applies memory.max via cgroups
    Note over S,C: if memory.current exceeds the limit
    S->>C: systemd-oomd sends SIGKILL
stateDiagram-v2
    [*] --> Normal
    Normal --> Pressure: available memory drops
    Pressure --> Normal: frees memory
    Pressure --> Killing: exceeds oomd threshold
    Killing --> Normal: container terminated
💡 Tip: adding swap in zram, compressed memory within RAM itself, gives the kernel a cushion before systemd-oomd has to kill anything, without wearing out flash storage.

Your next step: install Podman on any Raspberry Pi or 512 MB VM you have on hand and run podman run --rm quay.io/podman/hello to confirm that cgroups and rootless mode work before adding your first real service.

📬 Get new articles by email

We only email about big articles (1-2 a month).

Frequently Asked Questions

Does Podman Fully Replace Docker on a Low-RAM Home Server?

Yes, for most home self-hosting use cases. Podman uses the same CLI syntax and the same OCI image formats as Docker, but without a persistent daemon, which saves a fixed 30 to 60 MB.

How Much RAM Does Debian Minimal Need Before Running Any Service?

Between 80 and 120 MB at idle with systemd and basic daemons active, depending on what additional packages the base image you use includes.

Why Use Beszel Instead of Prometheus and Grafana on a 512 MB Device?

Because Prometheus and Grafana together already consume between 100 and 200 MB just to start, while Beszel covers CPU, RAM, and disk graphs with 20 MB total.

What Happens If a Podman Container Exceeds the Memory Limit I Assigned?

The kernel, via cgroups, kills it with an OOM kill inside its own cgroup before it affects the rest of the system, as long as you set --memory when you created it.

Does This Low-RAM Self-Hosting Approach Work on a Raspberry Pi Zero?

Yes, the reasoning is the same. Debian or Raspberry Pi OS Lite as the base, systemd to supervise services, and rootless Podman for containers, adjusting memory limits to the actual total available.

Do You Need Swap for This Stack to Be Stable?

It’s not required, but zram, compressed swap within RAM, gives extra margin without wearing out eMMC storage, something valuable on devices with low-durability flash.

References

📱 Like this content? Follow @programacion on Telegram for daily tech content in Spanish: quick summaries, fresh content every day.

Featured image: Foto de BoliviaInteligente en Unsplash

Did it work for you? Got a different error? Say so below: questions get answered and help the next reader.

Leave a comment
Categories: Tech NewsTutorials

Andrés Morales

Developer and AI researcher. Writes about language models, frameworks, developer tooling, and open source releases. Covers ML papers, the tech startup ecosystem, and programming trends.

0 Comments

Leave a Reply

Avatar placeholder

Your email address will not be published. Required fields are marked *

You can include code inside <code>…</code> or, for several lines, <pre><code>…</code></pre>.

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