How to Self-Host linkding with Docker
linkding is a small self-hosted bookmark manager with tags, notes, sharing controls, a REST API, and optional page archiving. Its Docker Compose setup is simple, but linkding fetches bookmark URLs from the server, so an authenticated user can make the VPS contact other systems. The URL validation in 1.45.0 checks syntax only; it is not server-side request forgery protection.
This guide deploys linkding 1.45.0 from the official Docker Hub image on Debian or Ubuntu. Port 9090 stays bound to loopback, the first superuser is created before Caddy exposes the service, and host firewall rules block the container from private, link-local, and metadata address ranges.
Set up this way, linkding makes a pleasant private bookmark service for trusted users. The two habits worth keeping are treating the outbound fetcher as attack surface and testing your restores instead of assuming the copies work.
Prerequisites
Prepare:
- a Debian or Ubuntu VPS with root or sudo access;
- an A record for
bookmarks.example.compointing to the VPS, plus an AAAA record only when the complete IPv6 path is configured; - TCP 80 and 443 for Caddy and your existing administrative SSH port;
- a trusted administrator account and no plan to offer open registration;
- encrypted off-server backup storage;
- an unused private Docker subnet. This guide uses
172.31.50.0/24.
Check that the example subnet does not overlap an existing host or container route:
ip route
sudo docker network ls
If it overlaps, choose another unused RFC1918 /24 and replace both 172.31.50.0/24 and 172.31.50.10 everywhere below.
Install Docker Engine and the Compose plugin with Docker’s convenience script (skim it first if you want to know what it changes), then install the remaining packages:
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo docker compose version
sudo apt update
sudo apt install -y caddy ufw jq unzip
The script runs as root and adds Docker’s package repository; if that is unacceptable on your host, install Docker from your distribution’s packages instead.
Keep SSH allowed when configuring UFW. Replace port 22 if necessary:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
Apply the same inbound policy in any provider firewall. Port 9090 must never be public.
Create the linkding Compose project
The official installation guide uses one container, SQLite, and a persistent /etc/linkding/data mount. Its data directory holds db.sqlite3, secretkey.txt, page assets, favicons, preview images, and background-task state.
Create the project, data, and backup directories:
sudo install -d -m 750 -o "$USER" -g "$USER" /opt/linkding
cd /opt/linkding
umask 077
install -d -m 700 data backups
install -m 600 /dev/null .env
Create /opt/linkding/.env:
tee .env > /dev/null <<'ENV'
LD_DISABLE_URL_VALIDATION=False
LD_CSRF_TRUSTED_ORIGINS=https://bookmarks.example.com
LD_USE_X_FORWARDED_HOST=False
LD_LOG_X_FORWARDED_FOR=False
ENV
chmod 600 .env
Keeping LD_DISABLE_URL_VALIDATION=False preserves the normal HTTP/HTTPS syntax check. It does not make arbitrary destinations safe. LD_USE_X_FORWARDED_HOST=False avoids trusting a client-controlled forwarding header because Caddy preserves the ordinary Host header.
Create /opt/linkding/compose.yaml:
tee compose.yaml > /dev/null <<'YAML'
services:
linkding:
image: docker.io/sissbruecker/linkding:1.45.0
container_name: linkding
restart: unless-stopped
ports:
- "127.0.0.1:9090:9090"
volumes:
- ./data:/etc/linkding/data
env_file:
- .env
networks:
linkding_outbound:
ipv4_address: 172.31.50.10
logging:
options:
max-size: "10m"
max-file: "3"
networks:
linkding_outbound:
ipam:
config:
- subnet: 172.31.50.0/24
YAML
The custom network intentionally has no IPv6. Do not enable Docker IPv6 for this service until equivalent IPv6 egress rules block ::1, fc00::/7, fe80::/10, and any provider-specific metadata destinations.
Start the container and check the listener:
cd /opt/linkding
sudo docker compose up -d --wait --wait-timeout 180
curl -fsS http://127.0.0.1:9090/health
sudo ss -lntp | grep ':9090'
The official health endpoint checks the database connection and should return JSON with version 1.45.0 and status healthy. The host listener must be 127.0.0.1:9090.
Block private and metadata egress
linkding fetches page metadata with server-side HTTP requests. The plus image also runs Chromium and downloads PDF or HTML snapshots. The 1.45.0 validator delegates to Django’s URL validator and does not reject loopback, RFC1918, link-local, cloud metadata, or DNS answers that resolve there.
Install persistent iptables support, then add rules scoped to the fixed container address. The first rule permits reply traffic for connections initiated toward the published application port. The remaining rules reject new container connections to non-public IPv4 ranges, including 169.254.169.254 within 169.254.0.0/16.
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y iptables-persistent
sudo iptables -C DOCKER-USER -s 172.31.50.10 \
-m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null \
|| sudo iptables -I DOCKER-USER 1 -s 172.31.50.10 \
-m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
for cidr in \
0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 \
169.254.0.0/16 172.16.0.0/12 192.0.0.0/24 \
192.168.0.0/16 198.18.0.0/15 224.0.0.0/4 240.0.0.0/4
do
sudo iptables -C DOCKER-USER -s 172.31.50.10 -d "$cidr" -j REJECT 2>/dev/null \
|| sudo iptables -I DOCKER-USER 2 -s 172.31.50.10 -d "$cidr" -j REJECT
done
sudo netfilter-persistent save
These rules block routed private destinations after DNS resolution, so they also help against redirects and DNS rebinding. They do not make linkding safe for hostile tenants: a request to loopback inside the same container never traverses the host’s forwarding chain, and public destinations remain reachable. Keep every account trusted and isolate sensitive services from this Docker network.
After any Docker or firewall change, rerun this test from inside the container. It must reach a public site and fail on both blocked destinations:
sudo docker compose exec -T linkding python - <<'PY'
from urllib.error import HTTPError, URLError
from urllib.request import ProxyHandler, build_opener
opener = build_opener(ProxyHandler({}))
with opener.open("https://example.com/", timeout=15) as response:
assert 200 <= response.status < 400
for url in (
"http://169.254.169.254/latest/meta-data/",
"http://192.168.0.1/",
):
try:
opener.open(url, timeout=5)
except HTTPError as exc:
raise SystemExit(f"blocked destination returned HTTP {exc.code}: {url}")
except URLError:
pass
else:
raise SystemExit(f"blocked destination was reachable: {url}")
PY
The test covers routed destinations only. It says nothing about loopback inside the linkding container, other special-purpose prefixes, an IPv6 path, or a future Docker network with a different source address. Review the rules whenever networking changes.
Create the superuser before public exposure
The linkding image has no default user. Its official user setup provides an interactive Django command. Run it while port 9090 is still loopback-only so the password does not enter Compose, .env, or shell history:
cd /opt/linkding
sudo docker compose exec linkding \
python manage.py createsuperuser \
--username=admin \
[email protected]
Enter a strong unique password at the prompts. Do not set LD_SUPERUSER_PASSWORD for this deployment; it would leave a reusable administrator credential in the container environment.
Open an SSH tunnel from your workstation:
ssh -N -L 9090:127.0.0.1:9090 YOUR_USER@SERVER_IP
Browse to http://127.0.0.1:9090, sign in, sign out, and sign in again. Confirm that there is no public sign-up flow. Close the tunnel only after the account works.
Put Caddy in front of loopback port 9090
Caddy preserves the original Host header, which linkding needs for login and form submissions. Add the public route only after the administrator test:
sudo install -d -m 755 /etc/caddy/sites-enabled
sudo tee /etc/caddy/sites-enabled/linkding.caddy > /dev/null <<'CADDY'
bookmarks.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:9090
}
CADDY
if ! sudo grep -Eq \
'^[[:space:]]*import[[:space:]]+/etc/caddy/sites-enabled/\*' \
/etc/caddy/Caddyfile; then
printf '\nimport /etc/caddy/sites-enabled/*\n' | \
sudo tee -a /etc/caddy/Caddyfile > /dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
Caddy will obtain a certificate once DNS, UFW, and any provider firewall permit TCP 80/443. Check the redirect and the health endpoint:
curl -fsSI http://bookmarks.example.com/
curl -fsS https://bookmarks.example.com/health | jq .
If a save or login request returns 403 CSRF verification failed, confirm that .env contains the exact origin with the https:// scheme and no path. Do not solve it by trusting every origin or by publishing port 9090.
Run UI and API bookmark tests
Log in at https://bookmarks.example.com and create a bookmark for https://example.com. Set the title to Compose UI test, add the tag compose-test, and put Created through the browser in the description or notes field. Save, reload the page, search for Compose UI test, and confirm the title, tag, and note survive. This browser check tests persistence, not automatic metadata fetching.
Restart the container and check the same bookmark again:
cd /opt/linkding
sudo docker compose restart linkding
sudo docker compose up -d --wait --wait-timeout 180
curl -fsS https://bookmarks.example.com/health | jq -e \
'.status == "healthy" and .version == "1.45.0"'
linkding creates an API token for each user in the Settings page. Read it without echoing, then create a bookmark through the official REST API without supplying a title. A nonempty title in the saved record proves that server-side metadata fetching ran:
set -Eeuo pipefail
read -rsp 'linkding API token: ' LINKDING_TOKEN
echo
bookmark_id=''
cleanup_api_test() {
local rc=$?
if [ -n "$bookmark_id" ]; then
curl -fsS -o /dev/null -X DELETE \
-H "Authorization: Token $LINKDING_TOKEN" \
"https://bookmarks.example.com/api/bookmarks/$bookmark_id/" || true
fi
unset LINKDING_TOKEN
return "$rc"
}
trap cleanup_api_test EXIT
response=$(curl -fsS -X POST \
-H "Authorization: Token $LINKDING_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"url":"https://www.iana.org/help/example-domains","description":"Created through REST API","tag_names":["compose-test"]}' \
https://bookmarks.example.com/api/bookmarks/)
bookmark_id=$(printf '%s' "$response" | jq -er '.id')
for attempt in $(seq 1 12); do
saved=$(curl -fsS \
-H "Authorization: Token $LINKDING_TOKEN" \
"https://bookmarks.example.com/api/bookmarks/$bookmark_id/")
if jq -e '.title | type == "string" and length > 0' \
<<<"$saved" > /dev/null; then
break
fi
[ "$attempt" -lt 12 ] || {
echo 'linkding did not fetch a title within 60 seconds' >&2
exit 1
}
sleep 5
done
printf '%s' "$saved" | jq '{id,url,title,tag_names}'
curl -fsS -o /dev/null -X DELETE \
-H "Authorization: Token $LINKDING_TOKEN" \
"https://bookmarks.example.com/api/bookmarks/$bookmark_id/"
bookmark_id=''
unset LINKDING_TOKEN response saved
trap - EXIT
Delete the browser test bookmark when you are done with it. If the API title stays empty, inspect application logs, DNS, and the outbound rules before weakening any block.
Consider the optional Chromium image carefully
linkding 1.45.0 also publishes sissbruecker/linkding:1.45.0-plus. The archiving guide says this image adds Chromium, Node.js, SingleFile CLI, and uBlock Origin Lite so the server can create HTML snapshots. Direct PDF URLs are downloaded as assets.
On amd64, the compressed base image was about 136 MB when checked on July 14, 2026, while the plus image was about 556 MB. Upstream recommends at least 1 GB RAM for Chromium, and each retained snapshot consumes data-disk and backup space. Browser processes can also create short CPU and memory spikes, so it is worth watching the real workload for a while rather than sizing from the 1 GB figure alone.
Server-side snapshots expose the VPS egress IP and request timing to the target. They can trigger bot protection, and they do not inherit authenticated sessions from your desktop browser. Snapshots can retain private text, query parameters, or tracking identifiers long after a page changes. Use the browser-based SingleFile method when the exact logged-in page matters, and review the uploaded file before sharing it.
To adopt plus, change only the image line, pull, recreate, and repeat the snapshot and storage checks:
image: docker.io/sissbruecker/linkding:1.45.0-plus
Keep the same egress blocks. Chromium raises the stakes if an untrusted user can submit a URL.
As of July 14, 2026, the 1.45.0-plus registry manifest advertises linux/arm/v7 while upstream’s archiving documentation still says the plus image is unavailable for ARM v7. The two sources disagree, so on ARM v7, verify that Chromium starts, a snapshot completes, and memory stays within the target host’s limits before relying on plus.
Back up the database, assets, secret, and configuration
linkding’s backup guide provides full_backup. It uses SQLite’s online backup API and writes a ZIP containing the database, assets, favicons, and previews:
cd /opt/linkding
stamp=$(date +%F-%H%M%S)
sudo docker compose exec -T linkding \
python manage.py full_backup /etc/linkding/data/backup.zip
sudo install -m 600 -o "$USER" -g "$USER" \
data/backup.zip "backups/linkding-$stamp.zip"
sudo install -m 600 -o "$USER" -g "$USER" \
data/secretkey.txt "backups/secretkey-$stamp.txt"
sudo install -m 600 -o "$USER" -g "$USER" \
compose.yaml "backups/compose-$stamp.yaml"
sudo install -m 600 -o "$USER" -g "$USER" \
.env "backups/linkding-$stamp.env"
sudo rm -f data/backup.zip
test -s "backups/linkding-$stamp.zip"
Despite its name, full_backup in 1.45.0 does not include secretkey.txt, Compose, or .env. Preserve all three separately. The ZIP contains password hashes, API tokens, private bookmarks, notes, and possibly archived page content. Encrypt these files and copy them off-host with strict access controls.
Restore on port 19090 in isolation
Rehearse recovery in a unique directory with the same 1.45.0 image, a timestamped Compose project, port 19090 on loopback, and no Caddy route. An internal Docker network prevents restored background tasks from contacting bookmarked sites or the Wayback Machine. Keep all restore and cleanup commands in one shell.
set -Eeuo pipefail
restore_stamp="$(date +%Y%m%d%H%M%S)-$$"
restore_project="linkding_restore_$restore_stamp"
restore_dir="/opt/linkding-restore-$restore_stamp"
backup_zip=$(find /opt/linkding/backups -maxdepth 1 -type f \
-name 'linkding-*.zip' -printf '%p\n' | sort | tail -n 1)
test -s "$backup_zip"
backup_stamp=$(basename "$backup_zip")
backup_stamp=${backup_stamp#linkding-}
backup_stamp=${backup_stamp%.zip}
sudo install -d -m 700 -o "$USER" -g "$USER" "$restore_dir/data"
cd "$restore_dir"
unzip -q "$backup_zip" -d data
install -m 600 "/opt/linkding/backups/secretkey-$backup_stamp.txt" \
data/secretkey.txt
install -m 600 "/opt/linkding/backups/linkding-$backup_stamp.env" .env
While still in the unique $restore_dir created above, create compose.restore.yaml in the current directory:
services:
linkding:
image: docker.io/sissbruecker/linkding:1.45.0
ports:
- "127.0.0.1:19090:9090"
volumes:
- ./data:/etc/linkding/data
env_file:
- .env
networks:
- restore_only
restart: "no"
networks:
restore_only:
internal: true
Start the recovery project and check its health endpoint:
sudo docker compose -p "$restore_project" \
-f compose.restore.yaml up -d --wait --wait-timeout 180
curl -fsS http://127.0.0.1:19090/health | jq .
Tunnel workstation port 19090 to the VPS, log in with the restored account, and compare bookmark, tag, and note counts. Fetch several saved bookmarks without asking linkding to refresh them. Confirm the existing API token can read the restored API. If you use snapshots, open a sample from local restored assets while the source site remains unreachable from the internal network.
Remove the test project after recording a successful result:
sudo docker compose -p "$restore_project" \
-f compose.restore.yaml down
Never run down -v in the production directory. This bind-mount deployment does not need it, and the habit is dangerous around other stateful Compose projects.
Upgrade with a controlled recovery point
Read the linkding releases, run full_backup, save secretkey.txt, Compose, and .env, and verify the backup with the isolated same-version drill. Note the current image tag, change it to the reviewed release, then pull and recreate:
cd /opt/linkding
sudo docker compose pull linkding
sudo docker compose up -d --wait --wait-timeout 180
curl -fsS https://bookmarks.example.com/health | jq .
linkding runs Django migrations during container startup. Repeat administrator login, UI create/search/delete, API create/read/delete, and snapshot tests if applicable. If the migration fails, preserve the upgraded data directory for diagnosis and restore the old image together with the pre-upgrade ZIP and matching secretkey.txt. An image-only downgrade may not reverse a database migration.
Choosing a Riven Cloud VPS for linkding
For a private bookmark service, our Premium plan (2 vCPU, 4 GB RAM, 40 GB NVMe, 1 TB transfer) is a comfortable starting point, with plenty of headroom over the 1 GB RAM that upstream suggests for the Chromium-based plus image. If you archive heavily, snapshots and previews are what grow, so keep an eye on the data directory and step up to Ultra (8 GB RAM, 80 GB NVMe, 2 TB transfer) when disk becomes the constraint.
Every plan includes a 1 Gbps port, full root access, daily backups, and deployment in Tokyo or Singapore. Our plans are unmanaged, so you run the stack yourself and keep full control. Pick the location near the people opening the bookmark UI; if you browse from mainland China, a quick comparison of the Tokyo Looking Glass and Singapore Looking Glass from your own carrier will show which route serves you better. Current plan details are on the pricing page.
One note on the daily provider backups: they make a good extra layer, but keep running full_backup with the separate secret and configuration copies, and keep rehearsing the port-19090 restore. An application-level backup you have tested is the one you can count on.
Privacy surfaces, security limits, and non-fit cases
Bookmark metadata fetching tells target sites the VPS egress IP. If a user enables favicons, linkding’s default LD_FAVICON_PROVIDER is a Google endpoint and sends the bookmarked scheme and hostname to Google. Configure a provider you accept or leave favicons disabled. Internet Archive integration can submit bookmark URLs to the Wayback Machine, which is inappropriate for private or token-bearing URLs. Page snapshots may retain sensitive content in backups.
Sharing and public sharing default to off, but a user who enables them can create views or feeds available without login. Check those profile settings and test logged-out access. Never assume a bookmark is private merely because the main application has a password.
Leave LD_ENABLE_AUTH_PROXY=False unless an authentication proxy is deliberately designed and tested. When enabled, linkding trusts the configured identity header and can automatically create users. The edge proxy must remove any client-supplied copy of that header, insert only an authenticated identity, and remain the sole path to port 9090. A forged Remote-User style header can otherwise become an account takeover.
The URL validator and egress rules are layered controls, not proof that linkding is safe for arbitrary tenants. Keep registration closed, protect API tokens like passwords, monitor outbound traffic and disk growth, and isolate sensitive host or private-network services.
If nobody on your side wants to own TLS, firewall persistence, patches, encrypted backups, and restore tests, a managed bookmark service or a browser-local solution will serve you better. linkding is also a poor fit for untrusted multi-user hosting, for archives containing regulated or confidential pages, or anywhere server-side fetching cannot be permitted. If public sharing, hostile users, or reliable authenticated-page archiving are core requirements, pick a system designed for those boundaries rather than stretching this deployment to fit them.
Share