<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Rootless on ascia.tech</title>
    <link>https://ascia.tech/tags/rootless/</link>
    <description>Recent content in Rootless on ascia.tech</description>
    <generator>Hugo -- gohugo.io</generator>
    <language>en-US</language>
    <managingEditor>cmhobbs@ascia.tech (C.M. Hobbs)</managingEditor>
    <webMaster>cmhobbs@ascia.tech (C.M. Hobbs)</webMaster>
    <copyright>C.M. Hobbs</copyright>
    <lastBuildDate>Mon, 03 Aug 2026 00:00:00 +0000</lastBuildDate>
    <atom:link href="https://ascia.tech/tags/rootless/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Rootless Containers with Podman</title>
      <link>https://ascia.tech/blog/rootless-containers-with-podman/</link>
      <pubDate>Mon, 03 Aug 2026 00:00:00 +0000</pubDate><author>cmhobbs@ascia.tech (C.M. Hobbs)</author>
      <guid>https://ascia.tech/blog/rootless-containers-with-podman/</guid>
      <description>&lt;p&gt;I gave a talk at NWA Tech Fest called &amp;ldquo;Rootless Containers with Podman.&amp;rdquo; This&#xA;is the version that I congealed from the slides and my notes for the people&#xA;who asked for it afterwards (and like a month later because I&amp;rsquo;m forgetful).&#xA;The tone is probably a little too expository because I&#xA;chopped stuff directly out of my speaking script with minor edits, so try to read&#xA;it as if I&amp;rsquo;m yammering on at you at lunch and you can&amp;rsquo;t get away because your&#xA;phone battery is flat.  As an aside, I submitted a larger version of this talk to TXLF and it got&#xA;&lt;a href=&#34;https://pretalx.com/txlf2026/talk/review/GCQRWZN9DHLJCBLRMWACZFBRLXZHAPAC&#34;&gt;accepted!&lt;/a&gt;&#xA;I&amp;rsquo;ll be presenting on Day 2. Hope to see you there!&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>I gave a talk at NWA Tech Fest called &ldquo;Rootless Containers with Podman.&rdquo; This
is the version that I congealed from the slides and my notes for the people
who asked for it afterwards (and like a month later because I&rsquo;m forgetful).
The tone is probably a little too expository because I
chopped stuff directly out of my speaking script with minor edits, so try to read
it as if I&rsquo;m yammering on at you at lunch and you can&rsquo;t get away because your
phone battery is flat.  As an aside, I submitted a larger version of this talk to TXLF and it got
<a href="https://pretalx.com/txlf2026/talk/review/GCQRWZN9DHLJCBLRMWACZFBRLXZHAPAC">accepted!</a>
I&rsquo;ll be presenting on Day 2. Hope to see you there!</p>
<p>Also an exciting side note: Since I spoke at NWA Tech Fest, there was a major
<a href="https://github.com/podman-container-tools/podman/releases#release-v6.0.0">Podman release</a>.</p>
<p>Before I lean into this, I should probably also note that I&rsquo;m not associated
with the Podman project.  I&rsquo;m just a hella-enthusiastic user.</p>
<h2 id="the-talk-intro">The talk intro</h2>
<p>Docker is not the only container tool,
it isn&rsquo;t even the default on a lot of modern Linux. My favorite alternative can
run your containers with no root and no daemon anywhere on the box. I&rsquo;m not,
however, trying to sell anyone on switching. I deal with Docker daily for
clients, my local user is in the <code>docker</code> group right on the machine where I&rsquo;m
composing this post now, and before any of this I ran FreeBSD jails. This is
about knowing the alternative, because Docker gets all the attention and I think
the other option deserves some as well.</p>
<p>I didn&rsquo;t switch <em>to</em> Podman out of ideology. I&rsquo;ve run Fedora and the uBlue
atomic distros (Bluefin and Bazzite) for the last year or so, and they ship
Podman by default. If you&rsquo;re on Fedora, RHEL, or anything in that family, you
may already have Podman installed and not know it. Maybe it&rsquo;s worth a tinker?</p>
<h2 id="a-container-is-just-a-process">A container is just a process</h2>
<p>First myth to kill: a container is not a virtual machine. No guest kernel, no
virtual hardware, no hypervisor. A container is just a regular Linux process
running on your kernel. That&rsquo;s it. The magic is entirely in what the kernel lets
that process see and use.</p>
<p>There are basically two kernel features that do all the work:</p>
<ul>
<li><strong>Namespaces</strong> control what the process can <em>see</em>: its own process list, its
own network, its own filesystem mounts, its own users. The process thinks it&rsquo;s
alone on the machine.</li>
<li><strong>cgroups</strong> control what it can <em>use</em>: how much CPU, memory, and I/O. That&rsquo;s
what stops one container from eating all the resources.</li>
</ul>
<p>Everything else is plumbing on top of those two. Because a container is just a
process, it shares your host&rsquo;s kernel, which is why containers start in
milliseconds and weigh megabytes instead of gigabytes. It&rsquo;s also why the kernel
is your trust boundary.</p>
<p>An image (the thing you actually ship and pull) is filesystem layers plus
metadata describing how to run them. You pull an image, you run a container from
it.</p>
<p>This didn&rsquo;t just appear in 2013. <code>chroot</code> showed up in Unix around 1979(ish?)
and changed what a process saw as the root of the filesystem. BSD jails and
Solaris Zones took it further in the early 2000s. The real Linux ingredients,
namespaces and cgroups, landed in the kernel between 2006 and 2008. LXC built
the first real toolset on them in 2008. Then Docker arrived in 2013 and made
the whole thing <em>usable</em>, which won the mindshare. Credit where it&rsquo;s due. In
2015 the Open Container Initiative standardized the image and runtime formats,
keeping us from being locked into a single vendor. Podman itself showed up in
2018.</p>
<h2 id="meet-podman">Meet Podman</h2>
<p>The first big difference from Docker is the daemon, or rather the lack of one.
Docker runs a central daemon, <code>dockerd</code>, as root, all the time, and your <code>docker</code>
command just talks to it. Podman has no daemon by default. It&rsquo;s a CLI binary
that forks and execs the container directly as your user. No always-on root
process in the middle. If Podman itself crashed, your running containers wouldn&rsquo;t
care, because Podman was never babysitting them. (Before the <em>WELL ACKSHUALLY</em>
shows up: Podman <em>can</em> run a socket service for API compatibility when something
expects the Docker socket, but that&rsquo;s opt-in. The point I&rsquo;m trying to make is
that Docker <em>requires</em> a long-running root daemon and Podman doesn&rsquo;t.)</p>
<p>Podman uses the same OCI images as Docker, the same registries (Docker Hub,
Quay, GHCR), and the CLI is meant to be one-to-one. Most days you can literally
<code>alias docker=podman</code> and your muscle memory and scripts keep working: <code>podman run</code>, <code>podman ps</code>, <code>podman build</code>, and so forth translate well.</p>
<p>Where that falls apart is Compose. <code>docker-compose</code> and compose files lean on
Docker-specific behavior and the daemon socket, and the Podman side can get
fuzzy on anything non-trivial. If you live in compose files, budget time for
the rough edges.</p>
<p>Which leans into where Docker is genuinely smoother in my opinion: ubiquity.
Every tutorial/Stack Overflow answer assumes Docker, and the ecosystem is
enormous (projects like linuxserver.io have containerized basically everything
with Docker as the assumption). A lot of Docker&rsquo;s quirks exist because it was
the early shaper of all this and we live in the world it built. The rough edges
are historical&hellip;</p>
<p>One Podman feature with no Docker equivalent: <strong>pods</strong>. A pod is a group of
containers that share a network namespace, so they talk over localhost and you
manage them as a unit. That&rsquo;s where the name comes from, Podman is the <em>pod</em>
manager. A Podman pod is single-host. It borrows the Kubernetes pod <em>idea</em> but
there&rsquo;s no scheduling or any of that (and it&rsquo;s not Docker Swarm either). Podman
gives you the pod shape locally, and you can (usually) hand that straight to k8s
later.</p>
<h2 id="rootless-the-part-that-matters-to-me">Rootless, the part that matters to me</h2>
<p>This is the heart of it. Everything I&rsquo;m about to describe runs as my normal user
without <code>sudo</code> or root.</p>
<p>The part that was hardest to wrap my head around was the UIDs. Inside the
container, the process can be root, UID 0, with all the package-manager and
file-ownership powers it expects. But out on the host, that&rsquo;s just me, an
unprivileged user. The thing that reconciles those two facts is the <strong>user
namespace</strong>, a namespace specifically for user IDs that remaps them between the
container and the host.</p>
<p>When your account is set up, you get a range of subordinate UIDs in
<code>/etc/subuid</code>. On my machine it&rsquo;s <code>cmhobbs:524288:65536</code>, meaning I own 65,536
fake UIDs starting at 524288. Those are the IDs the container gets to play with.
So container root (UID 0) maps to my real user (UID 1000), and container UIDs 1
and up map into that subuid range. Root in the container == me outside the
container.</p>
<p>If something breaks out of a rootless container, it lands as my unprivileged
user, not as host root. Compare that to the classic Docker setup, where a
breakout from a root daemon can mean the whole machine. So the nice thing here
is that the blast radius shrinks. Same containers, dramatically smaller worst
case.</p>
<p>LIke most things in life, it isn&rsquo;t free. The main thing you give up is that as
a normal user you can&rsquo;t bind ports below 1024, so a rootless container can&rsquo;t just
grab port 80 or 53. At home I took the blunt path and lowered a kernel parameter
so my rootless Pi-hole could bind DNS:</p>





<pre tabindex="0"><code>net.ipv4.ip_unprivileged_port_start = 53</code></pre><p>It works, but it&rsquo;s hella janky becasue it lowers the bar for <em>every</em>
unprivileged process on the box, not just mine.  In production I&rsquo;d do it
properly, with one privileged door out front (like a reverse proxy)
and every app rootless on a high port behind it.  There are cleaner options too
(systemd socket activation, firewall redirects), but &ldquo;concentrate the privilege
in one place&rdquo; is what you want to do.</p>
<h2 id="an-example">An example</h2>
<p>I don&rsquo;t do live demos at conferences, so I was a coward and copied/pasted these
simple examples into my slides and transferred them here:</p>





<pre tabindex="0"><code>$ whoami
cmhobbs

$ podman run -d -p 8080:80 nginx
256356789fe4...

$ curl -s localhost:8080
&lt;!DOCTYPE html&gt; ... Welcome to nginx!

$ podman ps
... web  nginx  Up  0.0.0.0:8080-&gt;80/tcp</code></pre><p>I&rsquo;m <code>cmhobbs</code>, a regular user, and I never typed <code>sudo</code>.  I started an nginx web
server, curled it, and there it is in <code>podman ps</code>, looking exactly like Docker.</p>
<p>Digging deeper to show the UID weirdness:</p>





<pre tabindex="0"><code>$ podman top web huser user
HUSER   USER
1000    root

$ podman exec web id
uid=0(root) gid=0(root) groups=0(root)...

$ podman unshare cat /proc/self/uid_map
         0       1000          1
         1     524288      65536</code></pre><p><code>podman top</code> with the <code>huser</code> and <code>user</code> columns puts the host user next to the
container user, process by process:  host UID 1000 (me) is running as root
inside the container.  One process, two identities.  <code>podman exec id</code> proves it
from the inside:  UID 0, root, all the root groups.  And <code>uid_map</code> supports this:<br>
The first line, <code>0 1000 1</code>, says container UID 0 maps
to host UID 1000 (me).  The second says container UIDs from 1 up map into my
subuid range starting at 524288.</p>
<p>So there it is:  I&rsquo;m not root, the container thinks it is,
and the kernel quietly reconciles the two.</p>
<h2 id="running-real-services">Running real services</h2>
<p>A container you started by hand is a toy.  Real infrastructure has to survive a
reboot and start in order so we need an init system (which on every box I touch
means systemd).  Full disclosure:  I do not love systemd.  I wish occasional
minor inconveniences on Lennart Poettering&hellip; may his sleeves dampen when he
washes his hands.  However it&rsquo;s everywhere, I lost that fight years ago, and it&rsquo;s
genuinelygood at keeping rootless containers alive.  None of that is the point
of this talk&hellip;</p>
<p>The modern way to do this is <strong>Quadlets</strong>:  instead of writing a systemd unit
with <code>podman run</code> stuffed into <code>ExecStart</code>, you write a short declarative
<code>.container</code> file and Podman&rsquo;s generator emits the real <code>.service</code> unit for you.</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">[Container]</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">Image</span><span class="o">=</span><span class="s">docker.io/library/caddy:latest</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">PublishPort</span><span class="o">=</span><span class="s">8080:80</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">Volume</span><span class="o">=</span><span class="s">%h/caddy/data:/data:Z</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="na">AutoUpdate</span><span class="o">=</span><span class="s">registry</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="k">[Install]</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="na">WantedBy</span><span class="o">=</span><span class="s">default.target</span></span></span></code></pre></div><p>Three rootless-specific things to notice:  <code>%h</code> expands to my home directory,
the capital <code>Z</code> on the volume relabels it for SELinux so the container can read
its own data, and <code>AutoUpdate=registry</code> gives hands-off image updates on a
timer.  One more gotcha:  by default your user services die when
you log out.  <code>loginctl enable-linger</code> fixes that, so your containers start at
boot and keep running with nobody logged in.</p>
<p>I&rsquo;ve written this up already, so I won&rsquo;t re-derive it here.  If you
want the full how-to, see my two SmallOps posts:
<a href="/blog/smallops-with-podman-pt1/">Part 1</a> covers hand-rolled systemd units from
jinja2 templates (the old way, for historical reference), and
<a href="/blog/smallops-with-podman-pt2/">Part 2</a> covers the Quadlet replacement.  In my home
home this pattern runs Pi-hole, Jellyfin with GPU transcoding, Home Assistant
talking to a Zigbee dongle, Uptime Kuma, FreshRSS, and a dozen other things, all
rootless under systemd.  The same Quadlet-and-Ansible pattern runs in production
for some of my clients in industrial settings.</p>
<h2 id="the-foot-guns-i-found-along-the-way">The foot guns I found along the way</h2>
<p>I bumped into two sharp edges and both were straight out of the features that make rootless work.</p>
<h3 id="networking-a-container-cant-reach-its-own-host-by-lan-ip">Networking: a container can&rsquo;t reach its own host by LAN IP</h3>
<p>Kernel networking needs root, so rootless networking is faked in userspace
by a helper called <strong>pasta</strong> (from the passt project).  A pasta process sits
next to each rootless container and bridges its network to the host by reusing
the host&rsquo;s own IP, routes, and DNS.  It became Podman&rsquo;s default in 5.0,
replacing the older slirp4netns.  Faster and lighter but pointy-er:  by design, a
pasta container cannot reach its own host using the host&rsquo;s external LAN IP.
Outbound to the rest of the world is fine.  The host talking to itself by its
LAN address gets refused.</p>
<p>I learned that because I run Uptime Kuma, rootless, on a box I call ser3,
and it monitors the other services on that same box.  One morning the dashboard
lit up red:  every check ser3 ran against itself was failing, and yet every
service it was complaining about was up and serving traffic.</p>
<p>The checks resolve ser3&rsquo;s hostname to its LAN IP, <code>192.168.50.15</code>.  They&rsquo;d
worked for weeks.  The only thing that changed was a routine restart of the
container, and that restart was the first time it came up under the new pasta
default.  The old container had quietly kept its slirp4netns networking the
whole time, right up until it was restarted.  The diagnostic, from inside the
container:</p>





<pre tabindex="0"><code>$ curl 192.168.50.15:8123              ECONNREFUSED   # ser3 -&gt; itself     FAILS
$ curl 192.168.50.190:9090             200 OK         # ser3 -&gt; ser5       works
$ curl 1.1.1.1:443                     200 OK         # ser3 -&gt; internet   works
$ curl host.containers.internal:8123   200 OK         # via pasta&#39;s name   works</code></pre><p>Read that and it&rsquo;s clean:  only the box talking to itself by its external IP
fails.  Not the firewall, not the service being down, those would
fail differently.  That last line is the tell:  <code>host.containers.internal</code>
is a name pasta hands you that <em>does</em> route back to the host, so pasta isn&rsquo;t
broken, it&rsquo;s doing exactly what it says.</p>
<p>For a monitoring box whose whole job is reaching host-local services, the fix
was host networking:</p>





<pre tabindex="0"><code>--network=host</code></pre><p>The container shares the host&rsquo;s network stack outright, so localhost and the
LAN IP both work again and it binds its ports directly.  Making that change made every
self-check recover.</p>
<p>The lesson I&rsquo;d actually pull from this is about timing.  The default flipped
during an upgrade weeks earlier and nothing broke and it broke when a restart
finally put the container on the new default.  A long-running service can be
living on old behavior long after you &ldquo;changed&rdquo; something.  So when you bump
something that moves a default, restart the affected services on purpose and
watch them right then, instead of getting ambushed by an unrelated reboot weeks
later.</p>
<h3 id="file-ownership-youre-reading-the-wrong-side">File ownership: you&rsquo;re reading the wrong side</h3>
<p>The second edge comes straight from the UID remapping.  The container writes a
file &ldquo;as root,&rdquo; and on the host it lands owned by some UID up in the 524288
range, not by you.  The everyday version:  images from linuxserver.io (and there
are a lot of them) expect a user with <code>PUID 1000</code> and chown their data to it.
Under rootless that <code>1000</code> is <em>inside</em> the namespace, so a plain <code>chown 1000</code>
from the host sets the wrong owner and the container can&rsquo;t read its own files.
Fix it from inside the namespace instead:</p>





<pre tabindex="0"><code>podman unshare chown -R 1000:1000 &lt;path&gt;</code></pre><p>Podman enters the user namespaceand sets the owner the
container will actually see.  If ownership
looks scrambled under rootless, you&rsquo;re almost always looking at it from the host
side where the remapped IDs are meaningless.  Step into the namespace and the
numbers make sense again.</p>
<h2 id="a-few-footnotes">A few footnotes</h2>
<ul>
<li>If you don&rsquo;t want the command line, there&rsquo;s <strong>Podman Desktop</strong>, free and open
source.  It&rsquo;ll manage your Docker containers and your Podman containers in the
same window, so they coexist and you don&rsquo;t have to choose on day one.</li>
<li>Podman speaks Kubernetes:  <code>podman kube generate</code> turns a container or pod into
Kubernetes YAML, and <code>podman kube play</code> runs Kubernetes YAML locally.  A nice
way to develop against Kubernetes without standing up a cluster.</li>
</ul>
<h2 id="what-to-take-away">What to take away</h2>
<ul>
<li>A container is just a process the kernel tells stories to.</li>
<li>Rootless means container-root is only ever <em>you</em>, so a breakout stays contained
and the blast radius shrinks.</li>
<li>You can run real services this way today, on
hardware you already own.</li>
</ul>
<p>If you take one action:  tonight, <code>podman run</code> something.  You very likely
already have Podman installed.  Just try it.</p>
<h2 id="references">References</h2>
<ul>
<li><a href="https://docs.podman.io/en/latest/">Podman Documentation</a></li>
<li><a href="https://wiki.archlinux.org/title/Podman">Podman - ArchWiki</a></li>
<li><a href="https://passt.top/">pasta / passt project</a></li>
<li><a href="https://rootlesscontaine.rs/">rootless containers</a></li>
<li><a href="https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html">podman-systemd.unit(5) (Quadlets)</a></li>
<li><a href="https://www.redhat.com/en/blog/quadlet-podman">Make systemd better for Podman with Quadlet</a></li>
<li><a href="/blog/smallops-with-podman-pt1/">SmallOps Part 1: jinja2 systemd units</a></li>
<li><a href="/blog/smallops-with-podman-pt2/">SmallOps Part 2: Quadlets</a></li>
</ul>
]]></content:encoded>
    </item>
  </channel>
</rss>
