Linkwarden Non-Root Docker Setup: 3 Easy Steps and Mistakes
To run Linkwarden non-root with Docker, create the data, pgdata and meili_data folders yourself before the first docker compose up, then add user: "1000:1000" to all three services (linkwarden, postgres, meilisearch) in docker-compose.yml. For an existing install, stop the stack and run sudo chown -R 1000:1000 data pgdata meili_data first. That is the method in the official Running as a Non-Root User guide added with Linkwarden v2.16.1. Below is the whole setup with real output, plus the two mistakes that leave the stack in a restart loop.
Tested on 2026-10-27 on a Debian 12 KVM virtual machine (4 vCPU, 4 GB RAM) with Docker Engine 29.9.2 and Docker Compose v5.6.1, using ghcr.io/linkwarden/linkwarden:v2.16.3 (newest release that day), postgres:16-alpine and getmeili/meilisearch:v1.13.3 from the upstream docker-compose.yml. The host user is vagrant with UID and GID 1000. Every output below is copied from that lab; links were added through the Linkwarden API.
Contents
- Why run Linkwarden non-root
- New install: Linkwarden non-root in 3 steps
- Step 1: create the Linkwarden non-root folders as your user
- Step 2: add user: "1000:1000" to every Linkwarden non-root service
- Step 3: start Linkwarden non-root and verify
- Existing install: switch Linkwarden to non-root
- Without chown: restart loop
- With chown: same data, new owner
- Common Linkwarden non-root mistakes
- 1. Folders not created first
- 2. Error messages that point to the wrong folder
- 3. Only the Linkwarden service gets user:
- Does Linkwarden non-root need v2.16.1 or newer?
- Checklist
Why run Linkwarden non-root
Before Linkwarden non-root: with the upstream compose file, all three containers run as root and every file they write on the host belongs to root. After adding one link:
$ docker compose exec linkwarden id
uid=0(root) gid=0(root) groups=0(root)
$ docker compose top linkwarden | head -3
SERVICE # UID PID PPID C STIME TTY TIME CMD
linkwarden 1 root 9157 9131 0 14:05 ? 00:00:00 node /data/node_modules/.bin/concurrently -k -n web,worker cd /data/apps/web && exec next start cd /data/apps/worker && exec tsx worker.ts
linkwarden 1 root 9300 9157 12 14:05 ? 00:00:03 next-server (v15.5.21)
$ ls -ln /lab/root
-rw-r--r-- 1 0 0 621 Oct 27 14:05 compose.yml
drwxr-xr-x 3 0 0 4096 Oct 27 14:05 data
drwxr-xr-x 4 0 0 4096 Oct 27 14:05 meili_data
drwx------ 19 70 0 4096 Oct 27 14:05 pgdata
$ find /lab/root/data/archives -type f -exec ls -ln {} +
-rw-r--r-- 1 0 0 3416 Oct 27 14:05 /lab/root/data/archives/1/1.html
-rw-r--r-- 1 0 0 55045 Oct 27 14:05 /lab/root/data/archives/1/1.jpeg
-rw-r--r-- 1 0 0 28908 Oct 27 14:05 /lab/root/data/archives/1/1.pdf
-rw-r--r-- 1 0 0 3114 Oct 27 14:05 /lab/root/data/archives/1/1_readability.json
-rw-r--r-- 1 0 0 22300 Oct 27 14:05 /lab/root/data/archives/preview/1/1.jpeg
The preserved copies (HTML, screenshot, PDF, readable view, preview) are root-owned, and pgdata belongs to UID 70, the postgres user inside the Alpine image. Your normal user cannot clean them up:
$ rm -rf /lab/root/data/archives # as vagrant (UID 1000)
rm: cannot remove '/lab/root/data/archives/1/1.pdf': Permission denied
rm: cannot remove '/lab/root/data/archives/1/1_readability.json': Permission denied
rm: cannot remove '/lab/root/data/archives/1/1.jpeg': Permission denied
...
A process that runs as root inside a container is also root on the host files it can reach. Running Linkwarden non-root limits what a compromised container can do and gives you files you own.
New install: Linkwarden non-root in 3 steps
Start from the normal Linkwarden Docker setup (.env with NEXTAUTH_SECRET, POSTGRES_PASSWORD and MEILI_MASTER_KEY; see how to install Linkwarden with Docker). Check your own numbers with id; the lab user had 1000:
$ id vagrant
uid=1000(vagrant) gid=1000(vagrant) groups=1000(vagrant)
Step 1: create the Linkwarden non-root folders as your user
cd ~/linkwarden
mkdir -p data pgdata meili_data
$ ls -ln /lab/nonroot
-rw-r--r-- 1 0 0 687 Oct 27 14:34 compose.yml
drwxr-xr-x 2 1000 1000 4096 Oct 27 14:34 data
drwxr-xr-x 2 1000 1000 4096 Oct 27 14:34 meili_data
drwxr-xr-x 2 1000 1000 4096 Oct 27 14:34 pgdata
Step 2: add user: "1000:1000" to every Linkwarden non-root service
services:
postgres:
image: postgres:16-alpine
user: "1000:1000"
env_file: .env
restart: always
volumes:
- ./pgdata:/var/lib/postgresql/data
linkwarden:
env_file: .env
user: "1000:1000"
environment:
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres
restart: always
image: ghcr.io/linkwarden/linkwarden:v2.16.3
ports:
- 3000:3000
volumes:
- ./data:/data/data
depends_on:
- postgres
- meilisearch
meilisearch:
image: getmeili/meilisearch:v1.13.3
user: "1000:1000"
restart: always
env_file:
- .env
volumes:
- ./meili_data:/meili_data
The Compose user attribute overrides the image's default user (the Linkwarden image has none set, so it runs as root). Quote the value, as the docs do.
Step 3: start Linkwarden non-root and verify
$ docker compose up -d
Container nonroot-postgres-1 Started
Container nonroot-meilisearch-1 Started
Container nonroot-linkwarden-1 Started
web ready after 5 s
$ docker compose exec linkwarden id
uid=1000(node) gid=1000(node) groups=1000(node)
$ docker compose exec postgres id
uid=1000 gid=1000 groups=1000
$ docker compose exec meilisearch id
uid=1000 gid=1000 groups=1000
$ docker compose top linkwarden | head -3
SERVICE # UID PID PPID C STIME TTY TIME CMD
linkwarden 1 vagrant 10533 10502 0 14:34 ? 00:00:00 node /data/node_modules/.bin/concurrently ...
linkwarden 1 vagrant 10641 10533 12 14:35 ? 00:00:03 next-server (v15.5.21)
UID 1000 is called node inside the Linkwarden image and vagrant on the host: the names differ, the number is what counts. After adding a link to Linkwarden non-root, the preserved formats appear within about 25 seconds, owned by your user:

$ find /lab/nonroot/data/archives -type f -exec ls -ln {} +
-rw-r--r-- 1 1000 1000 3416 Oct 27 14:35 /lab/nonroot/data/archives/1/1.html
-rw-r--r-- 1 1000 1000 55045 Oct 27 14:35 /lab/nonroot/data/archives/1/1.jpeg
-rw-r--r-- 1 1000 1000 28908 Oct 27 14:35 /lab/nonroot/data/archives/1/1.pdf
-rw-r--r-- 1 1000 1000 3114 Oct 27 14:35 /lab/nonroot/data/archives/1/1_readability.json
-rw-r--r-- 1 1000 1000 22300 Oct 27 14:35 /lab/nonroot/data/archives/preview/1/1.jpeg
$ ls -ln /lab/nonroot/pgdata | head -3
total 128
-rw------- 1 1000 1000 3 Oct 27 14:34 PG_VERSION
drwx------ 5 1000 1000 4096 Oct 27 14:35 base
PostgreSQL's log is normal for a Linkwarden non-root database. The "owned by user postgres" line is just initdb's wording; the files really are 1000:1000 as shown above:
$ docker compose logs postgres | grep -iE 'owned by|warning|ready'
postgres-1 | The files belonging to this database system will be owned by user "postgres".
postgres-1 | 2026-10-27 14:34:59.764 UTC [25] WARNING: no usable system locales were found
postgres-1 | initdb: warning: enabling "trust" authentication for local connections
postgres-1 | 2026-10-27 14:35:00.112 UTC [32] LOG: database system is ready to accept connections
postgres-1 | PostgreSQL init process complete; ready for start up.
postgres-1 | 2026-10-27 14:35:00.343 UTC [1] LOG: database system is ready to accept connections
Backups now work as your user without sudo:
$ cp -r /lab/nonroot/data /tmp/backup-nonroot && du -sh /tmp/backup-nonroot
140K /tmp/backup-nonroot
Existing install: switch Linkwarden to non-root
To move an existing setup to Linkwarden non-root, the install from the first section (root-owned data and meili_data, pgdata owned by 70) got the three user: lines.
Without chown: restart loop
$ docker compose up -d
$ docker compose ps -a --format 'table {{.Service}}\t{{.State}}\t{{.Status}}'
SERVICE STATE STATUS
linkwarden running Up 1 second (health: starting)
meilisearch restarting Restarting (1) 7 seconds ago
postgres restarting Restarting (1) 7 seconds ago
$ docker compose logs postgres | tail -1
postgres-1 | initdb: error: could not access directory "/var/lib/postgresql/data": Permission denied
$ docker compose logs meilisearch | tail -1
meilisearch-1 | Error: Permission denied (os error 13)
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/login
000
PostgreSQL cannot even enter pgdata (mode 700, owner 70), and its entrypoint then tries to initialise a new cluster. Your data is still there; nothing was overwritten.
With chown: same data, new owner
$ docker compose down
$ sudo chown -R 1000:1000 data pgdata meili_data
$ ls -ln
drwxr-xr-x 3 1000 1000 4096 Oct 27 14:05 data
drwxr-xr-x 4 1000 1000 4096 Oct 27 14:05 meili_data
drwx------ 19 1000 1000 4096 Oct 27 14:06 pgdata
$ docker compose up -d
web ready after 3 s
old account login: OK
$ curl -s localhost:3000/api/v1/links -H "Authorization: Bearer $TOKEN" | jq -c '[.response[] | {id, url}]'
[{"id":1,"url":"https://example.com/"}]
$ docker compose exec linkwarden id
uid=1000(node) gid=1000(node) groups=1000(node)
The old account, the old link and its archives survived the move to Linkwarden non-root. A second link added after the switch was archived normally, and all files are now 1000:1000:
1000:1000 3416 /lab/root/data/archives/1/1.html
1000:1000 55045 /lab/root/data/archives/1/1.jpeg
1000:1000 28908 /lab/root/data/archives/1/1.pdf
1000:1000 282113 /lab/root/data/archives/1/2.html
1000:1000 83939 /lab/root/data/archives/1/2.jpeg
1000:1000 144545 /lab/root/data/archives/1/2.pdf
...
Common Linkwarden non-root mistakes
1. Folders not created first
If you add the user: lines but let Docker create the bind-mount folders, Docker creates them as root, mode 755:
$ ls -ln /lab/nodirs
drwxr-xr-x 2 0 0 4096 Oct 27 14:35 data
drwxr-xr-x 2 0 0 4096 Oct 27 14:35 meili_data
drwxr-xr-x 2 0 0 4096 Oct 27 14:35 pgdata
$ docker compose logs postgres | tail -1
postgres-1 | initdb: error: could not change permissions of directory "/var/lib/postgresql/data": Operation not permitted
$ docker compose logs meilisearch | tail -1
meilisearch-1 | Error: Permission denied (os error 13)
$ docker compose logs linkwarden | grep -iE 'error|denied|EACCES|fail' | head -2
linkwarden-1 | Error: P1001: Can't reach database server at `postgres:5432`
linkwarden-1 | Error: P1001: Can't reach database server at `postgres:5432`
The Linkwarden non-root error is only a consequence: the database never starts. Fix: docker compose down, sudo chown -R 1000:1000 data pgdata meili_data, start again. The same applies to any extra volume you add.
2. Error messages that point to the wrong folder
| Log line | Cause |
|---|---|
initdb: error: could not change permissions of directory ...: Operation not permitted | pgdata owned by root (created by Docker) |
initdb: error: could not access directory ...: Permission denied | pgdata from an old root install (owner 70, mode 700) |
Error: Permission denied (os error 13) (meilisearch) | meili_data not owned by 1000 |
Error: P1001: Can't reach database server at `postgres:5432` | postgres is restarting; fix postgres first |
One P1001 line right after a Linkwarden non-root start is normal (Linkwarden starts before PostgreSQL is ready); a stream of them is not.
3. Only the Linkwarden service gets user:
This works, but only half: the app runs as 1000 and writes 1000-owned archives, while PostgreSQL still runs as root and moves pgdata to UID 70:
$ docker compose exec linkwarden id; docker compose exec postgres id
uid=1000(node) gid=1000(node) groups=1000(node)
uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
$ ls -ln /lab/half
drwxr-xr-x 2 1000 1000 4096 Oct 27 14:36 data
drwxr-xr-x 4 1000 1000 4096 Oct 27 14:36 meili_data
drwx------ 19 70 1000 4096 Oct 27 14:36 pgdata
A full Linkwarden non-root setup gives every service the line, as the docs say.
Does Linkwarden non-root need v2.16.1 or newer?
The v2.16.1 release note says "Added support for running the Docker container as a non-root user". In this lab the same compose file with user: "1000:1000" also worked on v2.16.0: the web app started, a link was archived (5 files, 1000:1000) and id showed UID 1000. Both images have a root-owned /data and no default user:
$ docker run --rm --entrypoint sh ghcr.io/linkwarden/linkwarden:v2.16.0 -c 'ls -lnd /data /data/apps/web/.next; id'
drwxr-xr-x 1 0 0 4096 Jul 28 13:00 /data
drwxr-xr-x 5 0 0 4096 Jul 28 12:59 /data/apps/web/.next
uid=0(root) gid=0(root) groups=0(root)
$ docker image inspect ghcr.io/linkwarden/linkwarden:v2.16.3 --format 'User={{.Config.User}}'
User=
So the change in 2.16.1 is mainly the documented, supported way to run Linkwarden non-root. Still use a current image (v2.16.3 also brought faster queries at scale) and pin the tag instead of latest so an upgrade does not surprise you.
Checklist
id -uandid -g: use your numbers instead of 1000 if they differ.- New install:
mkdir -p data pgdata meili_databefore the firstup. - Existing install:
docker compose down, thensudo chown -R 1000:1000 data pgdata meili_data. - Add
user: "1000:1000"tolinkwarden,postgresandmeilisearch. - Verify Linkwarden non-root:
docker compose exec linkwarden idshowsuid=1000, and new files indata/archivesare yours.
After Linkwarden non-root, more self-hosting with Docker on this site: Docker Mailserver setup and WireGuard in Docker.
Comments
Post a Comment