SQLite in Rootless Podman: Paths, Permissions and UID Maps

When a container cannot open SQLite, preserve the driver’s error name and number first. unable to open database file and attempt to write a readonly database lead to different checks. Making only the database file writable may leave the actual boundary unchanged. The SQLite file experiment below is separate from the explanation of Podman identity mapping.

Three results from temporary SQLite files

On 2026-09-22, an ordinary Linux user ran the code below with Python 3.12.3, SQLite 3.45.1 and DELETE journal mode. It used an empty table and disposable files. These results separate a missing parent path, a read-only connection and a non-writable parent directory.

case Failed operation Error name sqlite_errorcode Primary code
missing-parent connect SQLITE_CANTOPEN 14 14
readonly-connection INSERT / mode=ro SQLITE_READONLY 8 8
readonly-parent INSERT / parent 0500 SQLITE_READONLY_DIRECTORY 1544 8

The primary code for 1544 is 1544 & 255 = 8. In this run, INSERT failed because a journal could not be created in the parent directory, even though the database file’s permissions were unchanged. A parent-directory write failure therefore should not always be classified as CANTOPEN 14. Compare the READONLY_DIRECTORY definition with the extended code exposed by the actual driver.

Executable code for error numbers and cleanup results

Save this as sqlite-errors.py and run python3 sqlite-errors.py as an ordinary Linux user. The example refuses sudo/root execution. It requires Python 3.11 or later for the sqlite_errorcode and sqlite_errorname attributes. It accepts no existing database path and uses only the standard library and a new temporary directory.

"""Three SQLite boundaries using only disposable files, never an existing DB."""
import json
import os
import sqlite3
import stat
import sys
import tempfile
from contextlib import closing
from pathlib import Path

if sys.version_info < (3, 11) or not sys.platform.startswith('linux'):
    raise SystemExit('Use Python 3.11+ on Linux for this permission example')
if os.geteuid() == 0:
    raise SystemExit('Run as an ordinary user, without sudo/root')

observed = []
def attempt(label, path, sql=None, *, uri=False):
    try:
        with closing(sqlite3.connect(path, uri=uri)) as con:
            if sql is not None:
                con.execute(sql)
                con.commit()
    except sqlite3.Error as exc:
        observed.append({'case': label, 'name': exc.sqlite_errorname,
                         'code': exc.sqlite_errorcode,
                         'primary': exc.sqlite_errorcode & 255})
    else:
        raise RuntimeError(label + ': expected an error; review this environment')

with tempfile.TemporaryDirectory(prefix='sqlite-boundary-') as temp:
    root = Path(temp)
    db = root / 'sample.db'
    with closing(sqlite3.connect(db)) as con:
        if con.execute('PRAGMA journal_mode=DELETE').fetchone()[0] != 'delete':
            raise RuntimeError('This example requires DELETE journal mode')
        con.execute('CREATE TABLE t(x)')
        con.commit()
    attempt('missing-parent', root / 'missing' / 'sample.db')
    attempt('readonly-connection', db.as_uri() + '?mode=ro',
            'INSERT INTO t VALUES (1)', uri=True)
    original_mode = stat.S_IMODE(root.stat().st_mode)
    try:
        root.chmod(0o500)
        attempt('readonly-parent', db, 'INSERT INTO t VALUES (1)')
    finally:
        root.chmod(original_mode)
    restored = stat.S_IMODE(root.stat().st_mode) == original_mode

print(json.dumps({'python': sys.version.split()[0], 'sqlite': sqlite3.sqlite_version,
                  'journal_mode': 'delete', 'cases': observed,
                  'directory_permissions_restored': restored,
                  'temporary_directory_removed': not root.exists()}, indent=2))

A successful example run collects three SQLite exceptions and exits with code 0. Compare cases with the table and check that directory_permissions_restored and temporary_directory_removed are both true. Both were true in the actual run, and the runner separately confirmed the temporary work directory was empty. An absent expected exception or a failed script execution does not count as a successful reproduction.

The finally block restores directory permissions, and context managers close connections and remove temporary files. This does not guarantee cleanup after a forced termination or system failure. If results differ, record Python and SQLite versions, journal mode, the driver’s extended information and the execution identity. The table describes these observed conditions, not a fixed outcome for every filesystem.

Match the container path and actual application identity

Run these inspection commands on your own Linux Podman host, replacing app with the container name. They require sh, cat and id inside the container. The commands were checked against official documentation; Podman was unavailable in this environment, so they were not executed here.

podman inspect app --format '{{json .Mounts}}'
podman top app pid user hpid huser
podman exec app id
podman exec app sh -c 'cat /proc/self/uid_map; cat /proc/self/gid_map'
podman unshare cat /proc/self/uid_map

Compare the host Source, container Destination and read-only setting in Mounts with the application’s database path. In podman top, user and huser help identify the process’s container and host users; pid and hpid distinguish the process. podman exec app id reports the new exec process’s identity, which may differ if the application dropped privileges after startup. There is no need to dump command arguments or the whole environment. See the podman top descriptors.

Read UID ranges with a hypothetical example

The following is a hypothetical mapping, not captured Podman output. Its columns are inside start ID, outside start ID and range length.

# inside_start outside_start length
0 1000 1
1 100000 65536

The first row maps inside 0 to outside 1000. The second maps 65536 IDs starting at inside 1 to outside 100000. Inside 1000 therefore becomes 100000 + (1000 - 1) = 100999. Call that a host UID only after establishing that “outside” actually refers to the host.

Reading /proc/self/uid_map within one’s own namespace shows the parent namespace in the outside column. Reading another process’s map from the host can instead use the reader’s namespace as the reference. Rootless Podman can introduce an intermediate namespace, requiring composition with the next map. Do not assume the container map equals the podman unshare map or apply container UID 1000 directly as host ownership. Compare the Linux uid_map viewing rules and Podman user-namespace options.

Options such as --userns=keep-id also affect mapping. GIDs and supplementary groups matter for the actual application’s file access too. podman unshare is unavailable with the remote client; inspect the actual Linux host in that case.

Choose the boundary that needs correction

Established condition Next action Assumption to avoid
Database path and mount destination differ Compare application settings, mount destination and parent path Changing file chmod is sufficient
A mode=ro connection attempts a write Check the intended read/write contract and connection settings Host ownership must be the cause
Path matches but journal creation fails Check parent write/search permissions for the actual app identity and read-only mounts A writable database file is sufficient
Denial is suspected on an SELinux host Examine AVC records, volume labels and sharing requirements separately Bulk chmod/chown resolves every boundary

:U can recursively change host ownership. :Z and :z have different sharing semantics and should not be applied indiscriminately. Do not change production volume ownership based on the hypothetical numbers above.

WAL uses neighboring files as well. Read-only WAL has specific permitted cases, so directory write access is not universally required for every read. Check the WAL conditions, then verify intended reads and a small approved write transaction under the actual application identity. Do not insert arbitrary test rows into production or delete WAL files. This execution validates SQLite file permissions, not a successful Podman or SELinux repair.