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.

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:

Linkwarden non-root: user 1000:1000 in compose.yml, linkwarden container id uid=1000, archives owned by 1000
Linkwarden v2.16.3 as 1000:1000: process and archive files owned by the host user (paths shortened).
$ 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 lineCause
initdb: error: could not change permissions of directory ...: Operation not permittedpgdata owned by root (created by Docker)
initdb: error: could not access directory ...: Permission deniedpgdata 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

  1. id -u and id -g: use your numbers instead of 1000 if they differ.
  2. New install: mkdir -p data pgdata meili_data before the first up.
  3. Existing install: docker compose down, then sudo chown -R 1000:1000 data pgdata meili_data.
  4. Add user: "1000:1000" to linkwarden, postgres and meilisearch.
  5. Verify Linkwarden non-root: docker compose exec linkwarden id shows uid=1000, and new files in data/archives are yours.

After Linkwarden non-root, more self-hosting with Docker on this site: Docker Mailserver setup and WireGuard in Docker.

Comments

Popular posts from this blog

How to Play Minecraft Bedrock Edition on Linux: A Comprehensive Guide for Tech Professionals

The Ultimate Guide: How to Set Up DXVK in Wine on Linux for Enhanced Gaming Performance

Linux desktop frozen? My 5 go-to tricks to try - before forcing a hard reboot