SSD Nodes Learn Hosting plans →
Hướng dẫn Matt ConnorBởi Matt Connor · Cập nhật ngày 2026-08-28

PUID và PGID trong Docker Compose là gì?

PUID và PGID không phải setting của Docker. Tìm hiểu quy ước entrypoint của linuxserver.io, vì sao file bind mount thành 911:911 và cách sửa đúng.

PUID và PGID thực sự là gì

PUID và PGID là 2 biến môi trường mà một số container image đọc khi khởi động. Docker không bao giờ tự đọc chúng. Đây là một quy ước được các image của linuxserver.io và một số image khác sử dụng. Vì vậy, image không được viết để đọc chúng sẽ lặng lẽ bỏ qua các biến này.

Bên trong image của linuxserver.io có một user tên là abc. User này được tạo lúc build với UID (user ID) 911 và GID (group ID) 911. Container khởi động với root, chạy các init script, rồi một trong các script đó đổi số ID của user này trước khi thực hiện bước nào khác:

groupmod -o -g "${PGID}" abc
usermod -o -u "${PUID}" abc

Flag -o cho phép sử dụng một ID đã được dùng ở nơi khác. Sau đó, init hạ quyền và chạy ứng dụng với user abc. Vì vậy, PUID=1000 không bao giờ được truyền đến Docker. Biến này đổi số ID của một user bên trong container trước khi ứng dụng khởi động. Do đó, mọi file mà ứng dụng ghi sẽ xuất hiện trên disk của bạn với owner là 1000. Nếu không đặt PUID, abc vẫn giữ ID 911. Đây là lý do bind mount chưa được cấu hình sẽ chứa đầy các file thuộc về 911:911.

Lấy hai số ID bằng id

Chạy lệnh này trên host, với user sở hữu các thư mục dữ liệu:

id
uid=1000(deploy) gid=1000(deploy) groups=1000(deploy),27(sudo),988(docker)

uid là PUID và gid là PGID của bạn. Khi dùng trong script, id -uid -g in ra chỉ các số ID. Trên hầu hết image VPS mới, tài khoản người dùng đầu tiên có UID:GID là 1000:1000, nhưng không được mặc định như vậy. Server được rebuild hoặc tài khoản thứ hai được thêm sau đó có thể dùng 1001 trở lên. Nhập sai số ở đây chính là toàn bộ nguyên nhân gây lỗi. Nếu các service chạy dưới một service account riêng thay vì user đăng nhập của bạn, hãy chạy id thatuser rồi lấy các số ID từ đó.

Vì sao file của bạn hiển thị là 911:911

ls -l hiển thị ID dạng số thay vì tên khi không có account trên host nào khớp với ID đó. Không có gì trên server của bạn có UID 911, nên không có tên để hiển thị. Dùng ls -ln để luôn hiển thị số và loại bỏ sự không rõ ràng:

ls -ln /srv/appdata/sonarr
drwxr-xr-x 2 911 911 4096 Aug  7 09:12 Backups
-rw-r--r-- 1 911 911  512 Aug  7 09:12 config.xml

Kết quả đó cho biết container đã chạy với các giá trị mặc định tích hợp sẵn. Hãy xác nhận từ bên trong container thay vì đoán:

docker exec sonarr id abc
docker compose logs sonarr | head -n 25

linuxserver init ghi kết quả vào startup log trên 2 dòng:

User UID:    911
User GID:    911

Nếu 2 dòng đó hiển thị 911 sau khi bạn đặt PUID=1000 trong file Compose, biến này chưa được truyền vào container. Nguyên nhân thường gặp là bạn đã sửa docker-compose.yml rồi chạy docker compose restart, lệnh này sử dụng lại container hiện có cùng environment ban đầu. Thay đổi environment cần dùng docker compose up -d để tạo lại container.

Vì sao bạn không thể xóa file do container tạo

Kernel so sánh các con số, không so sánh tên. Shell của bạn chạy với UID 1000. File thuộc về UID 911. Thư mục chứa file là drwxr-xr-x và cũng thuộc về 911, nên group và other có quyền đọc và thực thi nhưng không có quyền ghi. Muốn xóa file, bạn cần quyền ghi trên thư mục chứa file, không phải trên chính file đó. Vì vậy lỗi này vẫn xảy ra ngay cả khi bản thân file có vẻ vô hại:

rm: cannot remove '/srv/appdata/sonarr/config.xml': Permission denied

Container đang ghi file cũng gặp đúng vấn đề này từ phía ngược lại. Nếu thư mục trên host thuộc về user của bạn và có mode 755, còn ứng dụng chạy với UID 911, lần ghi đầu tiên của nó sẽ thất bại với Permission denied và ứng dụng sẽ báo lỗi theo cách riêng của nó. Trong ứng dụng .NET như Sonarr hoặc Radarr, lỗi đó sẽ hiện thành UnauthorizedAccessException: Access to the path '/data/downloads' is denied. Chuỗi quyền ở trước tên file cho biết bạn đang bị xét theo nhóm quyền nào trong ba nhóm. Đọc đúng drwxr-xr-x sẽ biến lỗi này từ khó hiểu thành hiển nhiên.

Đây là vấn đề riêng của bind mount. Khi Docker tạo một named volume trống rồi mount volume đó lên một đường dẫn đã tồn tại trong image, Docker sẽ sao chép nội dung của đường dẫn đó vào volume, bao gồm cả ownership và các permission bit. Vì vậy ứng dụng sẽ thấy một thư mục mà nó đã có quyền sở hữu. Bind mount không được xử lý như vậy: Docker mount thư mục trên host chính xác theo trạng thái hiện tại của nó. Đây là một trong những lý do thực tế để bạn biết khi nào bind mount phù hợp hơn named volume và khi nào không phù hợp.

Sửa thư mục đã sai quyền

Thiết lập PUID và PGID chỉ thay đổi cách ứng dụng hoạt động từ thời điểm đó trở đi. Nó không tự sửa các file đã tồn tại trên disk. Hãy stop stack, tự sửa ownership, rồi start lại:

docker compose down
sudo chown -R 1000:1000 /srv/appdata/sonarr
docker compose up -d

Dùng sudo chown -R "$(id -u):$(id -g)" /srv/appdata/sonarr nếu bạn không muốn tự nhập các con số. Thực hiện việc này khi container đã stopped, vì ứng dụng đang chạy và ghi file giữa lúc thực hiện chown đệ quy có thể khiến cây thư mục chỉ được sửa một phần và phát sinh thêm một vòng lỗi khó hiểu.

PUID và PGID không khắc phục được gì

Đây là phần thường gây lỗi cho những người đã làm mọi thứ đúng. Init của linuxserver chỉ chown đúng 3 path khi khởi động: /app, /config/defaults. Các media mount của bạn không nằm trong danh sách đó. /data, /downloads/tv được chuyển cho ứng dụng mà không thay đổi, vì vậy nếu phía host của các mount này có owner mà user trong container không thể ghi, container vẫn khởi động bình thường, hiển thị đúng UID trong banner, rồi fail ngay ở lần import đầu tiên.

Đó là hành vi đúng. Việc chạy chown đệ quy trên một media library 12 terabyte mỗi lần container khởi động sẽ là thảm họa. Điều đó có nghĩa là bạn phải tự xử lý các media directory. Đây cũng là những mount dễ phát sinh lỗi permission nhất. Loại lỗi này thường chỉ xuất hiện âm thầm trong application log nhiều giờ sau khi container trông vẫn khỏe mạnh. Vì vậy, kết nối một write test định kỳ với ntfy trên VPS của chính bạn để đẩy cảnh báo đến điện thoại là cách đơn giản và ít tốn kém để phát hiện lỗi trước khi một tuần thiếu episode cho bạn biết.

Ba cách kiểm soát user và khi nào nên dùng từng cách

Biến môi trường PUID và PGID

Cách này chỉ hoạt động với các image có entrypoint đọc những biến này. Cách này phổ biến vì container vẫn khởi động với root, tự thực hiện thiết lập, sửa /config, rồi mới hạ quyền. Docker Mods và các init script tùy chỉnh vẫn hoạt động. Đổi lại, bạn phải tin vào một quy ước thay vì một tính năng của platform, và tên biến không thống nhất giữa các project.

Khóa user: trong Compose

Đây là tính năng thực sự của Docker và hoạt động với mọi image, vì container runtime áp dụng khóa này trước khi code riêng của image chạy:

services:
  sonarr:
    image: lscr.io/linuxserver/sonarr:latest
    user: "1000:1000"

Process không bao giờ chạy với root, kể cả trong thời gian rất ngắn. Đây là một cải thiện bảo mật thực sự. Tuy nhiên, cách này cũng làm hỏng mọi thành phần trong entrypoint cần quyền root. Trên các image của linuxserver, project hỗ trợ cách này trên cơ sở nỗ lực hợp lý và chỉ với những image đã được kiểm thử. Các điểm cần lưu ý là: PUID và PGID không còn tác dụng, Docker Mods sẽ không chạy, custom service sẽ không chạy, và bạn phải tự chịu trách nhiệm về permission trên mọi volume được mount. Pattern được tài liệu hóa của họ kết hợp khóa này với một /run có quyền ghi:

user: 1000:1000
tmpfs:
  - /run:uid=1000,gid=1000,exec
security_opt:
  - no-new-privileges=true

Một tác động về giao diện khiến nhiều người bất ngờ. Một user: dạng số không có entry tương ứng trong /etc/passwd của container, nên các tool bên trong sẽ hiển thị whoami: cannot find name for user ID 1000. ID vẫn hợp lệ và việc truy cập file vẫn hoạt động bình thường. Chỉ bước tra cứu tên là thất bại.

Docker rootless

Docker rootless chạy chính daemon dưới user không có đặc quyền của bạn, nên không có thành phần nào trên máy chạy với root thực sự. Cách này thay đổi hoàn toàn cách tính ownership. Container UID 0 được ánh xạ vào UID của user trên host đang chạy Docker rootless. Container UID n với mọi n từ 1 trở lên được ánh xạ vào subuid + (n - 1), trong đó subuid là giá trị đầu của range được cấp cho bạn trong /etc/subuid/etc/subgid. Docker yêu cầu tại đó có ít nhất 65,536 subordinate ID.

Hãy đọc lại mapping này, vì nó đảo ngược khuyến nghị thông thường. Với Docker rootless, container ghi file dưới root sẽ tạo ra file thuộc về bạn. Container ghi file dưới UID 1000 sẽ tạo ra file thuộc về một subordinate ID khoảng 100999, và shell của bạn không thể thao tác với file đó. Vì vậy, giá trị PUID đúng trên daemon rootful lại là giá trị sai trong trường hợp này. Hai cơ chế giải quyết cùng một vấn đề nhưng ở các layer khác nhau. Việc kết hợp chúng mà không kiểm tra là nguyên nhân khiến nhiều người tạo ra một directory mà họ cần sudo mới xóa được. Nếu dùng rootless, hãy kiểm tra ownership của một file được ghi trên chính server của bạn trước khi migrate một library vào đó.

Với hầu hết stack self-host trên một VPS duy nhất, dùng PUID và PGID trên daemon chạy với root là lựa chọn thực tế, vì đó là cách các image được xây dựng và tài liệu hóa. Chọn user: khi README của image nói image đó đã được kiểm thử với cách này, hoặc khi bạn chạy image chính thức từ upstream nhưng image hoàn toàn không hỗ trợ PUID. Một workspace tài liệu như một instance AFFiNE self-host trên một VPS thuộc trường hợp thứ hai, vì không container nào của nó đọc PUID; quyền sở hữu thư mục database và các file đã upload do runtime quyết định, không phải do bất kỳ giá trị nào trong environment block. Điều tương tự cũng đúng với một helpdesk Chatwoot self-host, trong đó container Rails và worker Sidekiq cùng ghi vào một thư mục uploads, nhưng không container nào đọc PUID. Vì vậy, thư mục đó phải khớp với user mà image vốn đã chạy dưới user đó. Stack mới hơn cũng không thay đổi điều này. Vì vậy, cấp cho mỗi người trong team một sandbox OneCLI agent riêng vẫn để các thư mục workspace của từng người và thư mục dữ liệu Postgres thuộc về user mà mỗi image vốn đã chạy dưới user đó. Đây là vấn đề user:chown, không phải vấn đề PUID. Khi đặt một API self-host duy nhất phía trước Codex, Claude Code và Hermes, bạn cũng kế thừa cách bố trí tương tự, vì image đó chạy bằng user tích hợp sẵn của nó. Bind mount chứa database và các key đã lưu sẽ nhận quyền sở hữu tương ứng với user đó.

Trường hợp media stack: dùng chung một group giữa các container

Một media stack kiểu arr với Sonarr, Radarr và download client là lúc vấn đề này không còn chỉ là lý thuyết. Download client ghi file đã tải xong vào /data/downloads. Sau đó Sonarr tạo hardlink hoặc di chuyển file đó vào /data/media. Hardlink chỉ hoạt động khi hai container có quyền ghi vào cùng một cây thư mục. Nếu download client chạy với 1000 còn Sonarr chạy với 1001, một container sẽ sở hữu các file mà container kia chỉ có thể đọc.

Cách sửa là tạo một group dùng chung để mọi container trong stack sử dụng group đó làm PGID:

sudo groupadd -g 13000 media
sudo usermod -aG media deploy
sudo chown -R deploy:media /srv/media
sudo find /srv/media -type d -exec chmod 2775 {} +
sudo find /srv/media -type f -exec chmod 0664 {} +

2775 có bit setgid ở đầu là 2. Trên một directory, bit này có nghĩa là mọi file và subdirectory mới được tạo bên trong sẽ kế thừa group media thay vì primary group riêng của người tạo. Nhờ đó cấu hình vẫn hoạt động với các lượt tải mới mà bạn không phải chạy lại chown. Hãy log out rồi log in lại, hoặc chạy newgrp media, trước khi kiểm tra quyền truy cập của chính bạn. Group được thêm bằng usermod -aG sẽ không xuất hiện trong một shell session đã mở sẵn.

Bên trong container, groupmod -o -g 13000 abc đổi số của group abc thành 13000. Vì vậy abc ghi file với cùng GID như group media trên host. Mỗi container trong stack vẫn giữ PUID riêng và dùng chung một PGID. Điều này cũng áp dụng cho các container ở phía sau trong chuỗi, chỉ đọc thư viện đã hoàn tất, chẳng hạn chính Jellyfin và các frontend được gắn thêm vào nó như Halcyon, ứng dụng cung cấp thư viện đó dưới dạng một cửa hàng video thập niên 90 có thể duyệt.

Tiếp theo, đặt UMASK=002 cho mọi container linuxserver trong stack. Đây là bước nhiều người bỏ qua. Giá trị mặc định trong các image này là UMASK=022. Giá trị đó loại bỏ quyền ghi của group khỏi mọi file mới, nên file được tạo với quyền 0644 và cấu hình chia sẻ vừa thiết lập sẽ không có tác dụng. 002 tạo file với quyền 0664 và directory với quyền 0775, để group có thể ghi:

services:
  sonarr:
    image: lscr.io/linuxserver/sonarr:latest
    container_name: sonarr
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - UMASK=002
      - TZ=Etc/UTC
    volumes:
      - /srv/appdata/sonarr:/config
      - /srv/media:/data
    restart: unless-stopped

Đặt hai giá trị đó trong file .env bên cạnh file Compose. Nhờ vậy toàn bộ stack đọc cùng một định nghĩa:

PUID=1000
PGID=13000

Compose tự động đọc file đó để thay thế theo kiểu ${PUID}. Đây cũng là cơ chế dùng cho credentials. Các nguyên tắc đưa các giá trị ra khỏi docker-compose.yml và vào file .env cũng áp dụng ở đây, chỉ khác là hai số này không phải secret.

Hãy kiểm tra toàn bộ quy trình thay vì chỉ tin vào config. Ghi một file từ bên trong một container rồi đọc file đó trên host:

docker exec sonarr touch /data/downloads/permtest
ls -ln /srv/media/downloads/permtest

Kết quả đúng sẽ hiển thị PUID của bạn là owner, 13000 là group và -rw-rw-r-- là mode. Nếu group hiển thị 1000 thì directory đó thiếu bit setgid. Nếu mode hiển thị -rw-r--r-- thì biến UMASK chưa có hiệu lực. Hãy kiểm tra xem bạn đã tạo lại container thay vì chỉ restart nó chưa. Khi xong, xóa file kiểm tra bằng rm /srv/media/downloads/permtest.

Image nào dùng biến nào

Các image của linuxserver.io dùng PUID, PGIDUMASK. Paperless-ngx dùng tên khác cho cùng cơ chế: USERMAP_UIDUSERMAP_GID, cả hai đều mặc định là 1000. Tài liệu của ứng dụng hướng dẫn đọc các giá trị này từ id -uid -g. Các photo server cũng có cách triển khai khác nhau: PhotoPrism có cặp PHOTOPRISM_UIDPHOTOPRISM_GID riêng, còn Immich không có biến tương đương và để user của container do key user: của Docker quyết định. Vì vậy, chọn PhotoPrism hay Immich cũng quyết định cơ chế nào trong số này bạn sẽ phải duy trì cho thư viện lớn nhất trên máy. Nhiều image chính thức của upstream, bao gồm các image database và web server phổ biến, dùng một user cố định được tích hợp sẵn và yêu cầu bạn dùng user: hoặc giữ nguyên cấu hình đó. Các deployment nhỏ chỉ chạy một app cũng đặt ra câu hỏi tương tự. Vì vậy, khi triển khai openGym workout tracker tự host, bạn nên kiểm tra container thực sự chạy bằng user nào trước khi trỏ bind mount vào đó. Thư mục chứa database sẽ kế thừa user này, bất kể bạn có đặt PUID hay không. Remote access relay cũng thuộc cùng nhóm. Khi tự chạy RustDesk relay server, cặp key Ed25519 mà hbbs ghi khi khởi động lần đầu sẽ xuất hiện trong bind mount với ownership của user mà image đó sử dụng. Một chown ở phía host là cách sửa duy nhất bạn có thể dùng. Điều tương tự áp dụng cho các thành phần infrastructure bạn bổ sung sau này. Khi đặt Authentik trước các app để dùng một lần đăng nhập, bạn sẽ chạy các image chính thức của server, Postgres và Redis. Các image này không đọc PUID, nên ownership của volume do runtime quyết định thay vì một entrypoint mà bạn có thể cấu hình.

Vì vậy, hãy đọc README của từng image trước khi sao chép một block environment giữa các project. Docker truyền mọi environment variable bạn đặt vào container, dù bên trong container có đọc biến đó hay không. Một PUID không được thành phần nào sử dụng sẽ không tạo ra lỗi, cảnh báo hay tác dụng nào. Container chạy bằng user được khai báo ở cuối Dockerfile của chính image đó. Bạn có thể xác định user này qua ownership của các file mà container ghi ra. Hãy kiểm tra việc này trước khi thêm bất kỳ thành phần mới nào vào máy, bao gồm một stack quét bảo mật open-kritt tự host. File Compose sẽ cho biết các image có hỗ trợ PUID hay ownership của những thư mục bạn mount đã được cố định bởi chính các image đó.

FAQ

Vì sao các file Docker của tôi có owner là 911:911?

911 là UID và GID của user abc được tích hợp trong các image của linuxserver.io. Nếu thấy giá trị này, nghĩa là container khởi động mà không được đặt PUIDPGID, nên init script giữ nguyên các giá trị mặc định tích hợp. ls -l hiển thị các số thô vì trên host của bạn không có account nào có ID 911, nên không có tên để hiển thị. Đặt PUIDPGID bằng output của id, tạo lại container bằng docker compose up -d, rồi sửa owner của các file hiện có bằng sudo chown -R 1000:1000 trên thư mục bị ảnh hưởng.

PUID và PGID có hoạt động trên mọi Docker image không?

Không. Đây không phải là tính năng của Docker và Docker không bao giờ tự đọc chúng. Chúng chỉ hoạt động trên các image có entrypoint tự đọc chúng rồi gọi usermodgroupmod trước khi khởi động application. Đây là nhóm image linuxserver.io và một số project đã sao chép pattern này. Các project khác dùng tên khác, chẳng hạn USERMAP_UIDUSERMAP_GID trong paperless-ngx. Trên image không đọc các biến này, chúng được chấp nhận rồi bỏ qua mà không có cảnh báo.

Nên dùng PUID và PGID hay key user: trong Docker Compose?

Dùng PUIDPGID khi image hỗ trợ chúng, vì entrypoint vẫn chạy với root đủ lâu để sửa /config và khởi động đúng các service của image. Dùng user: khi image không hỗ trợ PUID, hoặc khi README của image nói rõ rằng image đã được kiểm thử khi chạy non-root. Trên image linuxserver, đặt user: sẽ làm PUID và PGID không còn tác dụng, ngăn Docker Mods và các service tùy chỉnh chạy, đồng thời khiến bạn phải tự quản lý permission của mọi volume được mount.

Sonarr có PUID đúng nhưng vẫn không thể di chuyển file. Lỗi ở đâu?

Kiểm tra lần lượt 3 điểm. Thứ nhất là chính media mount: init chỉ chown /app, /config/defaults, nên /data hoặc /downloads vẫn giữ owner hiện có trên host. Thứ hai là shared group: nếu download client và Sonarr chạy với các GID khác nhau, hai bên không thể sửa file của nhau. Vì vậy, hãy đặt cùng một PGID cho mọi container trong stack. Thứ ba là umask: image mặc định UMASK=022 tạo file với quyền 0644, không có group write bit, nên shared group hoàn toàn không có tác dụng. Đặt UMASK=002 và bật setgid bit trên các thư mục bằng chmod 2775 để file mới kế thừa group.