Skip to content

feat: add PUID/PGID support for Docker bind mount permission compatibility#143

Merged
marcpope merged 2 commits intomarcpope:mainfrom
addvanced:fix-docker-permissions
Apr 15, 2026
Merged

feat: add PUID/PGID support for Docker bind mount permission compatibility#143
marcpope merged 2 commits intomarcpope:mainfrom
addvanced:fix-docker-permissions

Conversation

@addvanced
Copy link
Copy Markdown
Contributor

@addvanced addvanced commented Apr 15, 2026

Summary

Related to Discussion: #121

Docker bind mounts cause permission issues when the container's www-data user (UID 33) doesn't match the host user. This is a common problem across all platforms when using bind mounts instead of Docker volumes.

Additionally, some filesystems (notably btrfs on Synology NAS) create directories without write permission bits even when ownership is correct, causing service startup failures.

This PR adds PUID/PGID environment variable support to remap the container's www-data user to match the host user, and introduces an ensure_dir() helper that always sets ownership and permissions together to ensure correct behavior across all filesystems.

Changes

  • Add PUID/PGID environment variables to Dockerfile (default: 33/33)
  • Add UID/GID remapping logic in entrypoint.sh with collision detection for existing system users/groups
  • Add ensure_dir() helper function that sets mkdir + chown + chmod atomically
  • Consolidate all directory creation into a single block that runs before any service starts
  • Replace deprecated mysqld_safe with mariadbd-safe
  • Add --skip-test-db to mysql_install_db
  • Add --no-install-recommends to apt-get calls to reduce image size (~70MB)
  • Consolidate RUN layers and clean up build caches (pip, composer, apt)
  • Add PUID/PGID to docker-compose.yml (commented out) and .env.example with usage instructions
  • Add docker-compose.yml, .env, docs/, and other non-build files to .dockerignore

Testing

  • Tested on Docker
  • Tested on bare metal
  • Agent changes tested on Linux
  • Agent changes tested on Windows

Tested on Synology NAS (DS series, btrfs) with Docker bind mount and PUID=1026/PGID=100. Verified clean startup of all services (MariaDB, ClickHouse, Apache, SSH), successful client creation, and correct file ownership on the host volume.

…hosts

Docker bind mounts on filesystems like btrfs (common on Synology NAS)
cause two distinct problems:

1. Files created by www-data (UID 33) inside the container appear as
   foreign UIDs on the host, making management difficult.

2. btrfs creates directories without write permission bits, even when
   ownership is correct. This causes ClickHouse, MariaDB, and the
   application itself to fail with "Permission denied" errors.

Changes:

Dockerfile:
- Add PUID/PGID environment variables (default: 33/33) for UID/GID
  remapping at container startup
- Add --no-install-recommends to apt-get calls to reduce image size
- Consolidate RUN layers and clean up build caches (pip, composer, apt)

entrypoint.sh:
- Add ensure_dir() helper that always sets mkdir + chown + chmod together,
  preventing btrfs from leaving directories without write bits
- Remap www-data UID/GID to match host user when PUID/PGID are set,
  with collision detection for existing system users/groups
- Consolidate all directory creation into a single unified block that
  runs before any service starts
- Replace deprecated mysqld_safe with mariadbd-safe
- Add --skip-test-db to mysql_install_db

Usage:

    services:
      bbs:
        image: marcpope/borgbackupserver
        environment:
          - PUID=1000
          - PGID=1000

docker-compose.yml:
- Add PUID/PGID environment variables (commented out with usage instructions)

.env.example:
- Add PUID/PGID with explanation of when they're needed

.dockerignore:
- Add docker-compose.yml, .env, .env.example, README.md, LICENSE, docs/
to avoid copying unnecessary files into the build context
Comment thread Dockerfile Outdated
@marcpope marcpope merged commit 4c705a1 into marcpope:main Apr 15, 2026
@marcpope
Copy link
Copy Markdown
Owner

We are adding some followup code to this.

marcpope added a commit that referenced this pull request Apr 15, 2026
Builds on #143. Treats UID/GID changes like a DB migration so users
don't have to run chown commands manually when they change PUID/PGID.

State file /var/bbs/config/.ownership records what the volume is
currently configured for. On startup, the entrypoint compares the
env (PUID/PGID/MYSQL_PUID/MYSQL_PGID/CH_PUID/CH_PGID) against the
recorded state. If they differ:

  1. Preflight guards abort on bad configs (root UID, service
     collisions, SSH-client UID collisions) with a clear message.
  2. The in-container user/group is remapped. Pre-existing system
     users/groups holding the target UID are shifted by +10000 to
     free the slot.
  3. Files on the volume matching the old UID or GID are chowned,
     using find -uid/-gid so untouched per-client SSH home dirs are
     left alone.
  4. New state is written atomically (tmp + rename) only after all
     chowns succeed — so a crash mid-migration retries next start.

Every step is logged with a timestamp. File counts are reported per
path before chowning, and elapsed seconds after, so users know what
the container is doing and why a restart took longer than usual.

Also extends PUID support to MariaDB (MYSQL_PUID/MYSQL_PGID) and
ClickHouse (CH_PUID/CH_PGID) for users who want every file under the
data volume managed as their host user, not just /var/bbs/home.

Tightens top-level modes on /var/bbs/mysql and /var/bbs/clickhouse
from 755 to 750.
@marcpope
Copy link
Copy Markdown
Owner

Thanks for this — merged and shipped as the foundation. I've layered some follow-up work on top so that users can change UIDs without ever touching the host filesystem manually, treating it like a database migration (commit 4133fb9).

What we added

1. Declarative state tracking (/var/bbs/config/.ownership)

The volume records the UIDs/GIDs it's currently configured for. On each startup the entrypoint compares the env vars against that record. If they match, it's a no-op. If they differ, a migration runs and the state is updated atomically (tmp file + rename) only after all chowns succeed — so a crash mid-migration retries cleanly on next start.

2. Automatic chown of existing files when PUID/PGID change

Previously, a user who'd already set up with PUID=33 (default) and then switched to PUID=1026 would have orphaned files on the host that they'd need to chown manually. Now the entrypoint does it with find -uid <old> -exec chown <new> so only files actually needing migration are touched — per-client SSH home dirs (which have their own UIDs) are left alone.

3. Preflight guards that fail fast with clear errors

Refuses to start if:

  • Any PUID/PGID is 0 (root)
  • App PUID collides with MariaDB or ClickHouse UIDs (would silently corrupt DB files when usermod -u runs)
  • PUID collides with an existing SSH client UID (reads /var/bbs/home/*/.uid to check)

The collision-to-+10000 shift is kept for the case where the target UID is held by some unrelated system user — but the dangerous collisions now abort with a message telling the user exactly what's wrong and which env var to fix.

4. Extended coverage to MariaDB and ClickHouse

Added MYSQL_PUID/MYSQL_PGID/CH_PUID/CH_PGID env vars. Your original PR remapped www-data only, which is enough for the primary bind-mount use case (accessing repo files on the host). These extras are for users who want every file under /var/bbs to be owned by their host user — e.g. so they can rm -rf the data volume as a non-root user. Same migration machinery, just applied to the other two services.

5. Detailed timestamped logging

Every step announces itself. File counts before, elapsed time after, explicit warnings when a chown may take a while on large repositories. No more "why did my container take 4 minutes to start?" questions.

Example log output when migrating from UID 33 → 1000:

[12:34:56] === UID/GID migration starting ===
[12:34:56] Volume was configured as: app=33:33  mysql=100:100  clickhouse=999:999
[12:34:56] Reconfiguring to:         app=1000:1000  mysql=100:100  clickhouse=999:999
[12:34:56] --- app (www-data) migration ---
[12:34:56]   from: UID=33 GID=33
[12:34:56]   to:   UID=1000 GID=1000
[12:34:56]   remapping group 'www-data' from GID 33 to 1000
[12:34:56]   remapping user 'www-data' from UID 33 to 1000
[12:34:56]   [/var/bbs/home] chowning 428193 entries (UID matches: 1, GID matches: 428193)
[12:34:56]           this can take several minutes on large repositories — do not cancel
[12:37:12]   [/var/bbs/home] completed in 136s
[12:37:12]   [/var/bbs/cache] chowning 14 entries (UID matches: 1, GID matches: 14)
[12:37:12]   [/var/bbs/cache] completed in 0s
[12:37:12] === UID/GID migration complete ===

6. Minor: tightened mysql / clickhouse dir modes

/var/bbs/mysql and /var/bbs/clickhouse go from 755750. Inner files already have their own modes, but defense in depth on the outer dir doesn't hurt.


Your core changes — the ensure_dir helper, the Dockerfile cleanups, the consolidated directory setup — all stand unchanged. They were exactly right; this is just the automation layer on top so end-users never have to run manual commands after changing env vars.

Thanks again for the contribution!

@marcpope
Copy link
Copy Markdown
Owner

Test results on Synology btrfs (v2.27.0-beta1)

Ran the full migration flow against a real Synology DSM7 NAS, btrfs volume, bind-mounted into a fresh container via Container Manager (Docker 24.0.2). Summary of what we did and what we found:

What we tested

Test 1 — Fresh container with defaults (no PUID/PGID set).

  • Empty bind mount directory on btrfs.
  • Container came up cleanly; all services started.
  • ensure_dir created every directory with correct owner + mode, no btrfs missing-write-bit issue reproduced.
  • /var/bbs/config/.ownership baseline file written on first run:
    APP_UID=33  APP_GID=33
    MYSQL_UID=100  MYSQL_GID=100
    CH_UID=999  CH_GID=999
    
  • Directories on host after startup (btrfs ACL marker + visible — Synology default):
    drwxr-xr-x  33  33              config
    drwxr-x---  100 administrators  mysql
    drwxr-x---  995                 clickhouse    ← not 999!
    
    Mode-tightening to 750 for mysql/clickhouse took effect. Good.

What we found (and fixed as beta2)

The ClickHouse directory on disk was owned by UID 995, GID 996, not the 999/999 we wrote to the state file. Checked inside the container:

$ docker exec bbs-beta-test id clickhouse
uid=995(clickhouse) gid=996(clickhouse) groups=996(clickhouse)
$ docker exec bbs-beta-test id mysql
uid=100(mysql) gid=101(mysql) groups=101(mysql)

Debian's apt-get install clickhouse-server / mariadb-server assigns system UIDs dynamically based on what's free at image-build time. On the current build, clickhouse landed at 995 and mysql's group at 101 — but a future rebuild could easily shift those.

If we'd kept the hardcoded DEFAULT_CH_UID=999, a subsequent CH_PUID=1000 migration would have done find -uid 999 (0 matches, since real files are 995), marked the migration "done", and left ClickHouse's on-disk files orphaned while the in-container user was remapped. Silent data access failure.

Fix (v2.27.0-beta2, just released): detect actual UIDs at startup with id -u www-data / id -u mysql / id -u clickhouse instead of hardcoding. Falls back to 33/100/999 only if the user doesn't exist (shouldn't happen — the Dockerfile installs the packages).

Next steps

Retesting with beta2 now. Will post migration runs (changed PUID, no-op restart, preflight rejection) once the beta2 image finishes publishing.

What this confirms about the PR

  • ensure_dir works correctly on btrfs — no missing write bits, no chmod/chown race, directories came up clean on first run.
  • UID/GID remap via usermod/groupmod works against btrfs-backed storage with no surprises.
  • Synology ACLs (+ marker) and btrfs subvol behavior did not interfere with any of the ownership operations.

Thanks again @addvanced — the foundation held up well under live testing. The bug we found was in the automation layer I wrote on top, not in your changes.

@marcpope
Copy link
Copy Markdown
Owner

Beta2 test results on Synology btrfs — all passing ✅

Reran the full test matrix against v2.27.0-beta2 (the fix for the dynamic-UID bug found in beta1). Target: a Synology DSM7 NAS, Container Manager (Docker 24.0.2), bind-mounted btrfs volume. All results below are from a real live run.

1. Fresh container, no PUID/PGID — baseline written correctly

Baseline .ownership now reflects the actual in-image UIDs detected via id -u:

APP_UID=33     APP_GID=33
MYSQL_UID=100  MYSQL_GID=101    ← was wrongly 100:100 in beta1
CH_UID=995     CH_GID=996       ← was wrongly 999:999 in beta1

Host-side ownership on btrfs matches exactly. All directories come up with correct modes (750 for mysql/clickhouse/backups, 1777 for tmp, 755 for the rest). No btrfs missing-write-bit issue. Container reaches === BBS Container Ready === in ~25s.

2. Restart with PUID=1027 PGID=100 — migration runs cleanly

Log output (timestamps abbreviated):

--- app (www-data) migration ---
  from: UID=33 GID=33
  to:   UID=1027 GID=100
  [/var/bbs/home]    chowning 1 entries (UID matches: 1, GID matches: 1)
  [/var/bbs/home]    completed in 0s
  [/var/bbs/cache]   chowning 1 entries
  [/var/bbs/cache]   completed in 0s
  [/var/bbs/backups] chowning 1 entries
  [/var/bbs/backups] completed in 0s
  [/var/bbs/tmp]     chowning 1 entries
  [/var/bbs/tmp]     completed in 0s
  [/var/bbs/config]  chowning 2 entries
  [/var/bbs/config]  completed in 0s
=== UID/GID migration complete ===

Host-side ownership after migration (confirms the primary use case):

drwxr-xr-x  <host-user>:users      home
drwxr-xr-x  <host-user>:users      cache
drwxr-xr-x  <host-user>:users      config
drwxr-x---  100:administrators     mysql          ← untouched, correct
drwxr-x---  995:996                clickhouse     ← untouched, correct

The Synology user can now read and manage the repository data directly on the host filesystem — which is the entire point of the PR.

3. Restart with same PUID=1027 — no-op confirmed

Zero migration output in the log. Container reaches Ready. The state file compare correctly skips the migration path.

4. PUID=100 (collides with MYSQL) — refused

!!! FATAL: invalid UID/GID configuration !!!
  PUID (100) collides with MYSQL_PUID. Pick distinct UIDs for each service.
  Fix the offending env var (e.g. in docker-compose.yml / .env) and restart.

Container exits with status 1. No damage done to the data volume.

5. PUID=0 — refused

!!! FATAL: invalid UID/GID configuration !!!
  PUID = 0 (root) is not allowed. Services must not run as root.
  Fix the offending env var (e.g. in docker-compose.yml / .env) and restart.

Same clean refusal.

Minor cosmetic observations (not blockers)

  • .ownership file itself is written by the root-running entrypoint and ends up owned by root:root (mode 644 so still readable). Could be chowned to www-data for consistency — filed as a polish item.
  • /var/bbs/backups contains server-self-backup archives owned by root:www-data because the scheduler writes them as root. Pre-existing behavior, unrelated to this PR. Worth a follow-up but not scoped here.

Outcome

  • btrfs-specific fix from the PR: works as intended on real Synology hardware
  • Full PUID/PGID migration: works end-to-end with correct chown behavior, no-op detection, and safety rejections
  • Mode tightening (mysql/clickhouse to 750): verified on disk
  • No data loss, no corruption, no hung states across any scenario

Ready to promote out of beta.

@marcpope
Copy link
Copy Markdown
Owner

Polish follow-up from the Synology tests

Addressed the two cosmetic items I called out in the beta2 report.

Fixed (0af279c): .ownership file now chowned to the app UID after write. Was ending up root:root because the entrypoint runs as root — readable thanks to mode 644, but inconsistent with the rest of /var/bbs/config.

Reverted my concern about /var/bbs/backups/bbs-backup-*.tar.gz: on closer look, bin/bbs-backup:146 deliberately sets ownership to root:www-data mode 640. That archive contains a full MariaDB dump and the APP_KEY encryption secret, so making it unwritable by the web user is correct defense-in-depth. I was wrong to flag it as a bug.

Not cutting a new beta for this — the state-file cosmetic doesn't warrant a build. Folding it into v2.27.0 when we promote.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants