<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Rootless on Lessons learned</title><link>https://eghuro.cz/tags/rootless/</link><description>Recent content in Rootless on Lessons learned</description><generator>Hugo</generator><language>en-us</language><copyright>© Alexandr Mansurov · Licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)</copyright><lastBuildDate>Thu, 15 Oct 2026 17:23:00 +0200</lastBuildDate><atom:link href="https://eghuro.cz/tags/rootless/index.xml" rel="self" type="application/rss+xml"/><item><title>Rootless Podman backup without app.ini, and a restore that fails with 'Cannot change ownership to uid 100999'</title><link>https://eghuro.cz/posts/rootless-podman-backup-missing-app-ini/</link><pubDate>Thu, 15 Oct 2026 17:23:00 +0200</pubDate><guid>https://eghuro.cz/posts/rootless-podman-backup-missing-app-ini/</guid><description>&lt;p&gt;Restoring a Gitea backup to a new host failed on every directory:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tar: data/gitea: Cannot change ownership to uid 100999, gid 100999: Invalid argument&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tar: data/gitea/queues: Cannot change ownership to uid 100999, gid 100999: Invalid argument&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That one is easy to fix. The worse problem made no noise at all, and I found it just before: the nightly backup didn&amp;rsquo;t contain &lt;code&gt;app.ini&lt;/code&gt;. If you back up a rootless Podman volume with a plain &lt;code&gt;tar&lt;/code&gt; as the host user, yours may be missing files too.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;p&gt;Gitea 1.27 and PostgreSQL ran in rootless Podman on a VPS, with Gitea&amp;rsquo;s data in a bind mount under &lt;code&gt;~/gitea/data&lt;/code&gt;. The nightly backup ran as the user who owns the containers:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;podman &lt;span class="nb"&gt;exec&lt;/span&gt; gitea_db_1 pg_dump -U gitea gitea &lt;span class="p"&gt;|&lt;/span&gt; bzip2 -z9 &amp;gt; ~/backup/gitea-db-&lt;span class="k"&gt;$(&lt;/span&gt;date +%y%m%d&lt;span class="k"&gt;)&lt;/span&gt;.sql.bz2&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tar c -C ~/gitea/data gitea &lt;span class="p"&gt;|&lt;/span&gt; bzip2 -z9 &amp;gt; ~/backup/gitea-data-&lt;span class="k"&gt;$(&lt;/span&gt;date +%y%m%d&lt;span class="k"&gt;)&lt;/span&gt;.tar.bz2&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The target was Gitea in Docker, inside an unprivileged LXC container on Debian.&lt;/p&gt;&#10;&lt;p&gt;The archive looked fine. It unpacked, and it contained all the repositories.&lt;/p&gt;&#10;&#10;&lt;h2 id="compare-the-archive-with-the-live-tree"&gt;Compare the archive with the live tree &lt;/h2&gt;&#10;&lt;p&gt;Before restoring, I compared the archive&amp;rsquo;s file list with the live data. List the live side from inside the user namespace, so that nothing is hidden from you:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ssh vps.example.com &lt;span class="s1"&gt;&amp;#39;cd ~/gitea/data &amp;amp;&amp;amp; podman unshare find gitea -type f&amp;#39;&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; sort &amp;gt; live.txt&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tar tjf gitea-data.tar.bz2 &lt;span class="p"&gt;|&lt;/span&gt; grep -v &lt;span class="s1"&gt;&amp;#39;/$&amp;#39;&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; sort &amp;gt; backup.txt&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;comm -23 live.txt backup.txt &amp;gt; missing.txt&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;wc -l live.txt backup.txt missing.txt&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 12359 live.txt&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 12342 backup.txt&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 17 missing.txt&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Among the 17 were the files Gitea can least do without:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gitea/gitea/conf/app.ini&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gitea/gitea/jwt/private.pem&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gitea/git/.ssh/authorized_keys&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gitea/gitea/avatars/... (3 files)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gitea/gitea/indexers/... (4 files)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gitea/gitea/sessions/... (2 files)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;app.ini&lt;/code&gt; holds Gitea&amp;rsquo;s &lt;code&gt;SECRET_KEY&lt;/code&gt;, its internal tokens, and the mail settings. &lt;code&gt;private.pem&lt;/code&gt; signs its OAuth2 tokens. A restore from this backup would have started with fresh secrets: anything encrypted with the old &lt;code&gt;SECRET_KEY&lt;/code&gt;, such as two-factor secrets, would no longer decrypt, and the mailer would be unconfigured.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-tar-skipped-them"&gt;Why tar skipped them &lt;/h2&gt;&#10;&lt;p&gt;Rootless Podman maps the container&amp;rsquo;s UIDs onto the subordinate range in &lt;code&gt;/etc/subuid&lt;/code&gt;. With the usual entry:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;deploy:100000:65536&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;container root is you, and container UID 1000 (Gitea&amp;rsquo;s &lt;code&gt;git&lt;/code&gt; user) is host UID 100000 + 999 = 100999. Files that Gitea creates with mode &lt;code&gt;0600&lt;/code&gt; belong to 100999, and you can&amp;rsquo;t read them from the host. The repository objects were &lt;code&gt;0644&lt;/code&gt; in &lt;code&gt;0755&lt;/code&gt; directories, so tar read those, and the archive looked complete.&lt;/p&gt;&#10;&lt;p&gt;tar does complain, on stderr, and exits with status 2. In &lt;code&gt;tar c ... | bzip2 &amp;gt; file&lt;/code&gt;, though, the pipeline&amp;rsquo;s exit status is bzip2&amp;rsquo;s, which is 0. Without &lt;code&gt;set -o pipefail&lt;/code&gt; and someone reading the job&amp;rsquo;s stderr, nothing tells you.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix-the-backup"&gt;Fix the backup &lt;/h2&gt;&#10;&lt;p&gt;Run tar inside the user namespace, where you are root over every mapped UID:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -o pipefail&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;podman unshare tar c -C ~/gitea/data gitea &lt;span class="p"&gt;|&lt;/span&gt; bzip2 -z9 &amp;gt; ~/backup/gitea-data-&lt;span class="k"&gt;$(&lt;/span&gt;date +%y%m%d&lt;span class="k"&gt;)&lt;/span&gt;.tar.bz2&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The archive then records the IDs as the container sees them (1000), not the host&amp;rsquo;s 100999.&lt;/p&gt;&#10;&lt;p&gt;To rescue the files without touching the live host, fetch only the missing ones the same way, then merge them into the old archive with bsdtar&amp;rsquo;s &lt;code&gt;@archive&lt;/code&gt; syntax:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ssh vps.example.com &lt;span class="s1"&gt;&amp;#39;cd ~/gitea/data &amp;amp;&amp;amp; podman unshare tar -c -T -&amp;#39;&lt;/span&gt; &amp;lt; missing.txt &amp;gt; missing.tar&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;bsdtar -czf gitea-complete.tar.gz @gitea-data.tar.bz2 @missing.tar&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="fix-the-restore"&gt;Fix the restore &lt;/h2&gt;&#10;&lt;p&gt;GNU tar run as root restores the owners recorded in the archive (&lt;code&gt;--same-owner&lt;/code&gt; is its default for root). Inside an unprivileged LXC container, root is itself a mapped user, and the container&amp;rsquo;s UIDs usually cover only 0 to 65535. UID 100999 doesn&amp;rsquo;t exist there, so &lt;code&gt;chown&lt;/code&gt; fails with &lt;code&gt;EINVAL&lt;/code&gt;, which tar reports as &lt;code&gt;Invalid argument&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Don&amp;rsquo;t restore owners from an archive made somewhere else. Unpack with &lt;code&gt;--no-same-owner&lt;/code&gt; and set the owners the new Gitea expects:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tar -C /var/tmp/gitea-restore --no-same-owner -xzf gitea-complete.tar.gz&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cp -a /var/tmp/gitea-restore/gitea/. /srv/gitea/data/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;chown -R 1000:1000 /srv/gitea/data/git /srv/gitea/data/gitea&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;chown -R root:root /srv/gitea/data/ssh&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This also covers the merged archive, which carries 100999 for some files and 1000 for others.&lt;/p&gt;&#10;&lt;p&gt;After the restore, check against the live instance: the repository count, &lt;code&gt;git fsck&lt;/code&gt; on every repository, and the SSH host key fingerprint on the Git SSH port, so existing clones still trust the new host. All three matched.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;Under rootless Podman, a host-side &lt;code&gt;tar&lt;/code&gt; leaves out the files the container keeps private, which are often its secrets, and the pipeline still exits 0. Comparing the archive&amp;rsquo;s listing with &lt;code&gt;podman unshare find&lt;/code&gt; takes a minute and shows the gap.&lt;/p&gt;&#10;&lt;p&gt;Run the &lt;code&gt;podman unshare find&lt;/code&gt; and &lt;code&gt;comm&lt;/code&gt; check above against your own latest backup and see what comes back.&lt;/p&gt;&#10;</description></item></channel></rss>