Can I self-host Pinboard?

YES · ONE COMMAND— setup effort 1 of 4

YES — it's called linkding. It takes one prompt, a 512 MB VPS, and about 8 minutes. That is $1.83 a month you stop paying Pinboard — $21.96 a year on the Standard plan.

  • pinboard.in
  • Reading & bookmarks
  • prices checked 2026-08-06

Why people pay for Pinboard

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

Pinboard is one person. Maciej Ceglowski founded it in 2009, works on it full-time, answers the mail himself, and the links people saved on their first day still resolve. That is the product: a small yearly bill attached to somebody who has kept a boring promise for over fifteen years, plus an archival tier that crawls and keeps its own copy of every page you bookmark. Almost nobody leaves Pinboard to save money, and it would be dishonest to pretend otherwise.

Pinboard plans and list prices
PlanList priceWhat it buys
Standardthe plan this page prices against$1.83/mo$22 a year, billed yearly and paid up front. Pinboard sells no monthly plan, so 1.83 is the yearly price divided by twelve.
Archival$3.25/mo$39 a year, also billed yearly. Adds a stored copy of every page you bookmark, full-text search across those copies and a dead-link check. 3.25 is the yearly price divided by twelve.

Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: medium

Replaced by linkding

One project, named before the prompt, so you know what you are about to install.

A tag-first bookmark manager in one container: save the link, type the tags, find it again in a year.

The one that keeps the shape of the thing you are leaving. Open the page, paste a URL, type tags, close the page: no collections to design, no workspace to set up, and a tag list and a search box to get back to it later. It is one container with a SQLite file behind it, its settings page imports the HTML bookmark file that browsers and bookmarking services hand you on the way out, and it does not try to become a read-later app while you are not looking. What it does not do is keep a copy of the page, which is what the archival tier was for.

What else we looked at

Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.

  1. linkdingour pickONE COMMAND— setup effort 1 of 4

    A tag-first bookmark manager in one container: save the link, type the tags, find it again in a year.

    The one that keeps the shape of the thing you are leaving. Open the page, paste a URL, type tags, close the page: no collections to design, no workspace to set up, and a tag list and a search box to get back to it later. It is one container with a SQLite file behind it, its settings page imports the HTML bookmark file that browsers and bookmarking services hand you on the way out, and it does not try to become a read-later app while you are not looking. What it does not do is keep a copy of the page, which is what the archival tier was for.

  2. LinkwardenONE EVENING— setup effort 2 of 4

    Bookmarks that keep their own copy of the page, so a dead link is still readable years later.

    Pick this one instead if the archival tier is what you were actually paying for. Linkwarden's whole point is preservation: it keeps its own copy of each page, so a link that dies is still readable years later, and it sorts them into collections you can share with other people. That costs you a PostgreSQL, a headless Chromium doing the preserving, and more RAM than linkding needs, and saving a link is a heavier act every time. Worth it if you have watched bookmarked pages disappear; overkill if what you want back is the list.

The swap

You're paying

Pinboard

$1.83/mo · $21.96/yr

is replaced by

You'd run

linkding

ONE COMMAND · ~8 min to running · 512 MB RAM

Pinboard Standard · vendor list price · checked 2026-08-06 · source · confidence: medium

Before you start

RAM floor
512 MBfloor from upstream docs — not measured by us yet
Disk
5 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~8 minunder 10 minutes, through the first backup

The prompt

Two paths to the same linkding: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

314 lines · 14,669 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install linkding 1.45.0 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server. The same hostname is written into the config in
step 3, as the one origin the login form accepts a POST from, so changing the hostname later
means editing that file as well as the Caddy block.

linkding needs 512 MB of RAM available and 5 GB free on /srv. The image publishes amd64, arm64
and armv7. Measure all four:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 512 MB or free disk is under 5 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot get a
certificate for a name that does not resolve. That 512 MB is for the plain image this prompt
installs. The `-plus` image snapshots pages as HTML with a bundled Chromium, and upstream asks
for at least 1 GB of RAM to run it; step 11 says to leave it alone.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/linkding /srv/linkding/backups
sudo install -d -m 750 -o 33 -g 33 /srv/linkding/data
ls -la /srv/linkding
```

Assert: `ls -la` shows `backups` owned by the login user and `data` owned by uid `33` at mode
`750`. The container starts as root, runs its migrations, then hands the web server to
`www-data`, uid 33 in the image's Debian base, and chowns the data folder to that uid on every
start. Creating it as 33 now means the first start has nothing to change. Everything for this
service lives under /srv/linkding.

## 3. Secrets

One secret: the password for the only account this install will have. Generate it on the
server. Do not print it, do not repeat it in your summary, and do not put it in any log line.

```bash
umask 077
cat > /srv/linkding/.env <<EOF
LD_SUPERUSER_NAME=admin
LD_SUPERUSER_PASSWORD=$(openssl rand -hex 24)
LD_CSRF_TRUSTED_ORIGINS=https://<DOMAIN>
EOF
chmod 600 /srv/linkding/.env
umask 022
ls -l /srv/linkding/.env
```

Replace `<DOMAIN>` on the last line with the real hostname before you write the file. Assert:
the file exists with mode `-rw-------`. Hex rather than base64 for two reasons. Docker Compose
reads this same file for variable interpolation, so a `$` inside a value would be expanded,
and the user has to read this password once and paste it into a login form.

Upstream documents `LD_SUPERUSER_NAME` and `LD_SUPERUSER_PASSWORD` as an initial superuser
created once during container start-up, and states that one created with no password cannot
use the login form at all. Tell the user their password is in /srv/linkding/.env, that they
read it with `sudo grep LD_SUPERUSER_PASSWORD /srv/linkding/.env`, and that it belongs in
their password manager before step 7. linkding publishes no sign-up page and sends no
password-reset mail, so that file and their password manager are the only two places it
exists.

## 4. compose.yml

```bash
cat > /srv/linkding/compose.yml <<'EOF'
# linkding · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ....... https://linkding.link/installation/
#   options reference .... https://linkding.link/options/
#   backups .............. https://linkding.link/backups/
#   archiving ............ https://linkding.link/archiving/
#   proxy and CSRF ....... https://linkding.link/troubleshooting/
#
# One service. Bookmarks, tags and notes are rows in a SQLite database under
# /etc/linkding/data, so there is no second container and no database password
# anywhere. This is the plain image, not the -plus variant, which adds Chromium
# to snapshot pages as HTML and wants at least 1 GB of RAM. The container starts
# as root, migrates the database, then hands the web server to www-data and
# chowns /etc/linkding/data to that uid (33) on every start, which is why the
# install creates the host folder owned by 33. The image carries its own
# HEALTHCHECK against /health, so `docker compose ps` reports health with
# nothing declared here. Tag and digest read from Docker Hub on 2026-08-06; the
# image publishes amd64, arm64 and armv7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  linkding:
    image: sissbruecker/linkding:1.45.0@sha256:61b2eb9eed8e5772a473fb7f1f8923e046cb8cbbeb50e88150afd5ff287d4060
    container_name: linkding
    restart: unless-stopped
    # LD_SUPERUSER_NAME, LD_SUPERUSER_PASSWORD and LD_CSRF_TRUSTED_ORIGINS,
    # mode 600, never printed.
    env_file: /srv/linkding/.env
    environment:
      # SQLite is upstream's default and the choice here. Nothing to operate.
      LD_DB_ENGINE: sqlite
      # Left on (the default). The worker files Wayback Machine snapshots
      # only for an account whose owner turned that on.
      LD_DISABLE_BACKGROUND_TASKS: "False"
      # Every request arrives from Caddy on 127.0.0.1, so without this the
      # access log records the proxy rather than the browser.
      LD_LOG_X_FORWARDED_FOR: "true"
    volumes:
      # db.sqlite3, plus the assets, favicons and previews folders.
      - /srv/linkding/data:/etc/linkding/data
      # Where the backup step writes its zip.
      - /srv/linkding/backups:/backups
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8118.
      - "127.0.0.1:8118:9090"
EOF
cd /srv/linkding && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, one database file, and no
connection string to get wrong.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-linkding
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# linkding · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://linkding.link/installation/,
# https://linkding.link/troubleshooting/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. Upstream states
# that Caddy passes the Host header through unchanged and needs no extra
# directive for it. The same hostname is also LD_CSRF_TRUSTED_ORIGINS in .env,
# because linkding speaks plain http behind this proxy while the browser sends
# an https Origin, and Django compares the two.

<DOMAIN> {
	# The bookmark list and the JSON API compress well.
	encode zstd gzip

	# linkding sets its own X-Frame-Options: DENY, so this block does not
	# repeat it and must not weaken it. HSTS is here because every request to
	# this host carries a session cookie.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8118 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8118
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-linkding, reload, and report what it objected to. Caddy requests
the certificate on the first request to the hostname and renews it on its own, so there is
nothing to schedule.

## 6. Firewall

Two ports open, both Caddy's. Idempotent, so on a box Prompt Zero configured they change
nothing:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, and
443/udp is HTTP/3. 8118 stays closed because compose binds it to 127.0.0.1 and Caddy is the
only thing that speaks to it. Assert: `ufw status verbose` prints `Status: active`, shows 80,
443/tcp and 443/udp, and no rule mentioning 8118 or 9090.

## 7. Start and verify

The container generates its Django secret key, runs the migrations, turns on SQLite WAL mode
and creates the account named in `LD_SUPERUSER_NAME`, all on the way up.

```bash
cd /srv/linkding
docker compose pull
docker compose up -d
for i in $(seq 1 24); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS https://<DOMAIN>/health; echo
curl -sS https://<DOMAIN>/login/ | grep -o 'id="main-heading">Login'
docker compose exec -T linkding python manage.py shell -c "from django.contrib.auth import get_user_model; print(get_user_model().objects.count())"
```

Assert all four, and print what you received for each. The loop ends printing `200`. The
health response reads `{"version": "1.45.0", "status": "healthy"}`, where `status` is the
assert and the version confirms which image is running. The grep prints
`id="main-heading">Login`, the heading on the first screen. The last command prints `1`, which
is the account step 3 created and the whole account list. If any of the four misses, stop, run
`docker compose logs --tail 40 linkding`, and say which earlier step is the likely cause: a
container that exits immediately usually means step 2 left `data` owned by somebody other than
uid 33, a `502` from Caddy against a healthy container means step 5, and a user count of `0`
means the `.env` in step 3 was written after the container first started. A running container
is not success.

The first screen at https://<DOMAIN> redirects to https://<DOMAIN>/login/ and shows the
heading `Login` over `Username` and `Password` boxes and a `Login` button, with the browser
tab reading `Login - Linkding`. There is no register link and no route behind one: upstream
ships no sign-up page, so that count of `1` is the entire access-control surface here.

STOP: tell the user to read their password with `sudo grep LD_SUPERUSER_PASSWORD
/srv/linkding/.env`, put it in their password manager, log in at https://<DOMAIN> as `admin`,
save one bookmark, and wait. Do not continue until they confirm.

```bash
docker compose exec -T linkding python manage.py shell -c "from bookmarks.models import Bookmark; print(Bookmark.objects.count())"
```

Assert: a number greater than 0. Print it. That is a link that went through the browser,
through Caddy, through the login form and into the database, which is the whole product
working end to end. A `403` in the browser at the moment they pressed Login is step 3: see
step 10.

## 8. First backup and restore

Two artifacts. The zip holds the bookmarks. The config archive holds the files that rebuild the
service around them.

```bash
cd /srv/linkding
docker compose exec -T linkding python manage.py full_backup /backups/linkding-$(date +%F).zip
sudo tar -czf /srv/linkding/backups/linkding-config-$(date +%F).tar.gz -C /srv/linkding compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/linkding/backups/
```

Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped, because
`full_backup` is upstream's own command and it copies the database through SQLite's backup API
rather than reading the file. Upstream warns plainly against copying `db.sqlite3` with `cp`:
that is not transaction safe and can produce a corrupted database.

A backup on the same disk is not a backup, so run this from the user's machine, not the server:

```bash
mkdir -p ~/backups/linkding
scp vps:/srv/linkding/backups/* ~/backups/linkding/
```

To restore: `docker compose down`, `sudo rm -rf /srv/linkding/data`, recreate it exactly as in
step 2, then unpack the zip into it with a one-off container that has the same volumes and no
published port, `docker compose run --rm linkding python -m zipfile -e
/backups/linkding-$(date +%F).zip /etc/linkding/data` with the date of the archive being
restored, then `docker compose up -d`. The zip holds `db.sqlite3` and the `assets`,
`favicons` and `previews` folders at its top level. It does not hold `secretkey.txt`, which the
container writes again on start, so every session is signed out after a restore and the same
password signs back in. Tell the user those five commands are the whole disaster plan.

## 9. Updating later

New versions are listed at https://github.com/sissbruecker/linkding/releases. Take both backup
artifacts first, then edit the image line in /srv/linkding/compose.yml to the new tag and its
digest:

```bash
cd /srv/linkding
docker compose pull
docker compose up -d
docker compose logs --tail 30 linkding
```

linkding migrates its own database on the way up. Watch that log until it settles, then re-run
the `/health` check from step 7 before calling the update done.

## 10. What will probably go wrong

The login form will look broken before anything else does. linkding speaks plain http to
Caddy, so the framework behind it believes the request arrived over http, while the browser
sends an `Origin` header that says https, and a mismatch there answers the login POST with a
bare `403 CSRF verification failed` page and no other explanation. I lost ten minutes to that
screen convinced the password was wrong. The `LD_CSRF_TRUSTED_ORIGINS` line step 3 writes is
what stops it, which means it has to carry the exact scheme and hostname the user types, with
no trailing path and no port. If the user ever moves this install to a different hostname,
that line moves with it or the login stops working again.

## 11. Out of scope

- Do not switch to the `-plus` image. It bundles a Chromium to snapshot pages as HTML, upstream
  asks for at least 1 GB of RAM before it will behave, and this prompt sized the box for the
  plain one.
- Do not set `LD_DB_ENGINE` to `postgres`. SQLite is the choice here, and it is what the backup
  command in step 8 knows how to snapshot.
- Do not enable `LD_ENABLE_AUTH_PROXY` or `LD_ENABLE_OIDC`. Both hand authentication to an
  identity provider that is a separate install on a separate hostname.
- Do not configure SMTP. linkding sends no mail, so there is nothing for it to do.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.

You are installing linkding 1.45.0 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps`
unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already
points at the box.

Read this before step 1. That hostname is written into two places, the Caddy site block in
step 5 and a line called `LD_CSRF_TRUSTED_ORIGINS` in step 3, and the second one is why the
login form will work. If you move this install to a different hostname later, both have to
move with it.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `512` MB available, at least `5` G free, `amd64` or `arm64`, and your
server's IP on the last line. The image also publishes armv7, so an old Raspberry Pi is fine.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. That 512 MB floor is
for the plain image these steps install. The `-plus` image, the one that saves a copy of every
page you bookmark, ships a Chromium and upstream asks for at least 1 GB of RAM on top.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/linkding /srv/linkding/backups
sudo install -d -m 750 -o 33 -g 33 /srv/linkding/data
ls -la /srv/linkding
```

You should see: `backups` owned by you, and `data` at mode `drwxr-x---` owned by uid `33`,
which `ls` may print as `www-data`.

If you do not: leave `data` owned by 33 on purpose. The container starts as root, runs its
database migrations, then hands the web server to `www-data`, which is uid 33 in the image's
Debian base, and it chowns that folder to uid 33 on every start. Creating it that way now means
the first start has nothing to change, and it is also why reading files in there later needs
`sudo`.

## 3. Secrets

One secret: the password for the only account this install will have. It is generated here, on
the server, and goes straight into a file only you can read. Replace `<DOMAIN>` on the last
line with your real hostname before you paste.

```bash
umask 077
cat > /srv/linkding/.env <<EOF
LD_SUPERUSER_NAME=admin
LD_SUPERUSER_PASSWORD=$(openssl rand -hex 24)
LD_CSRF_TRUSTED_ORIGINS=https://<DOMAIN>
EOF
chmod 600 /srv/linkding/.env
umask 022
ls -l /srv/linkding/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Read the password
once with `sudo grep LD_SUPERUSER_PASSWORD /srv/linkding/.env` and put it in your password
manager now. linkding publishes no sign-up page and sends no password-reset mail, so that file
and your password manager are the only two places it exists, and the way back from losing both
is a command on the server.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/linkding/.env` and
carry on. If the file already existed from an earlier attempt, this block has now overwritten
the password, which is fine before the container has ever started and useless afterwards: the
account keeps the password it was created with, and `LD_SUPERUSER_NAME` does nothing once the
user exists.

Do not paste that file, the password, or any command output containing it into this chat
window. The value is hex rather than base64 for two reasons: Docker Compose reads this same
file for variable interpolation, so a `$` inside a value would be expanded, and you have to
type or paste this one into a login form.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/linkding/compose.yml <<'EOF'
# linkding · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ....... https://linkding.link/installation/
#   options reference .... https://linkding.link/options/
#   backups .............. https://linkding.link/backups/
#   archiving ............ https://linkding.link/archiving/
#   proxy and CSRF ....... https://linkding.link/troubleshooting/
#
# One service. Bookmarks, tags and notes are rows in a SQLite database under
# /etc/linkding/data, so there is no second container and no database password
# anywhere. This is the plain image, not the -plus variant, which adds Chromium
# to snapshot pages as HTML and wants at least 1 GB of RAM. The container starts
# as root, migrates the database, then hands the web server to www-data and
# chowns /etc/linkding/data to that uid (33) on every start, which is why the
# install creates the host folder owned by 33. The image carries its own
# HEALTHCHECK against /health, so `docker compose ps` reports health with
# nothing declared here. Tag and digest read from Docker Hub on 2026-08-06; the
# image publishes amd64, arm64 and armv7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  linkding:
    image: sissbruecker/linkding:1.45.0@sha256:61b2eb9eed8e5772a473fb7f1f8923e046cb8cbbeb50e88150afd5ff287d4060
    container_name: linkding
    restart: unless-stopped
    # LD_SUPERUSER_NAME, LD_SUPERUSER_PASSWORD and LD_CSRF_TRUSTED_ORIGINS,
    # mode 600, never printed.
    env_file: /srv/linkding/.env
    environment:
      # SQLite is upstream's default and the choice here. Nothing to operate.
      LD_DB_ENGINE: sqlite
      # Left on (the default). The worker files Wayback Machine snapshots
      # only for an account whose owner turned that on.
      LD_DISABLE_BACKGROUND_TASKS: "False"
      # Every request arrives from Caddy on 127.0.0.1, so without this the
      # access log records the proxy rather than the browser.
      LD_LOG_X_FORWARDED_FOR: "true"
    volumes:
      # db.sqlite3, plus the assets, favicons and previews folders.
      - /srv/linkding/data:/etc/linkding/data
      # Where the backup step writes its zip.
      - /srv/linkding/backups:/backups
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8118.
      - "127.0.0.1:8118:9090"
EOF
cd /srv/linkding && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/linkding/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/linkding/compose.yml` and paste again in one go. The image's own health check runs
`curl` against `/health` inside the container every 30 seconds, which is why nothing here
declares one.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in
the block with your hostname before you paste. The first line takes a copy, because a syntax
error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-linkding
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# linkding · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://linkding.link/installation/,
# https://linkding.link/troubleshooting/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. Upstream states
# that Caddy passes the Host header through unchanged and needs no extra
# directive for it. The same hostname is also LD_CSRF_TRUSTED_ORIGINS in .env,
# because linkding speaks plain http behind this proxy while the browser sends
# an https Origin, and Django compares the two.

<DOMAIN> {
	# The bookmark list and the JSON API compress well.
	encode zstd gzip

	# linkding sets its own X-Frame-Options: DENY, so this block does not
	# repeat it and must not weaken it. HSTS is here because every request to
	# this host carries a session cookie.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8118 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8118
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-linkding /etc/caddy/Caddyfile`, reload,
and paste again. Caddy requests the certificate on the first request to the hostname and renews
it on its own, so there is nothing to schedule and no certificate path anywhere in that block.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8118` or `9090`.

If you do not: delete anything for `8118` with `sudo ufw delete allow 8118`. 8118 is bound to
127.0.0.1 by the compose file, so no machine other than this one can open it and a firewall
rule would be theatre. 80/tcp is there to redirect to HTTPS and to answer the ACME challenge,
443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by default. `Status:
inactive` is a different problem: Prompt Zero left this firewall enabled, so something has
turned it off since, and `sudo ufw enable` puts it back before you go any further.

## 7. Start and verify

The container generates its secret key, runs the migrations, turns on SQLite WAL mode and
creates the account named in `LD_SUPERUSER_NAME`, all on the way up.

```bash
cd /srv/linkding
docker compose pull
docker compose up -d
for i in $(seq 1 24); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS https://<DOMAIN>/health; echo
curl -sS https://<DOMAIN>/login/ | grep -o 'id="main-heading">Login'
docker compose exec -T linkding python manage.py shell -c "from django.contrib.auth import get_user_model; print(get_user_model().objects.count())"
```

You should see, in order: the loop reaching `200`, then
`{"version": "1.45.0", "status": "healthy"}`, then `id="main-heading">Login`, then `1`.

If you do not: a loop that never reaches `200` is usually the certificate, so run `sudo
journalctl -u caddy --no-pager -n 30` and look for an ACME failure before you touch the
container. A container that exits immediately is step 2 done differently: run `docker compose
logs --tail 40 linkding`. A `0` from the last command means the `.env` was written after the
container had already started once, so run `docker compose down`, then `docker compose up -d`,
then that command again. That `1` is the whole account list on this server: there is no
sign-up page in linkding and no route behind one, so nobody else can make an account without
your password.

Now open https://<DOMAIN> in a browser. It redirects to https://<DOMAIN>/login/ and shows the
heading `Login` over `Username` and `Password` boxes and a `Login` button, with the browser tab
reading `Login - Linkding`. Log in as `admin` with the password from step 3, and save one
bookmark. Then, back on the server:

```bash
docker compose exec -T linkding python manage.py shell -c "from bookmarks.models import Bookmark; print(Bookmark.objects.count())"
```

You should see: a number greater than `0`. That is a link that went through the browser,
through Caddy, through the login form and into the database, which is the whole product
working end to end. A running container was not proof of that; this is.

If you do not: a `403` page saying `CSRF verification failed` at the moment you pressed Login
is step 3, not your password. See step 10.

## 8. First backup and restore

Two artifacts. The zip holds the bookmarks: `db.sqlite3` plus the `assets`, `favicons` and
`previews` folders. The config archive holds the files that rebuild the service around it.

```bash
cd /srv/linkding
docker compose exec -T linkding python manage.py full_backup /backups/linkding-$(date +%F).zip
sudo tar -czf /srv/linkding/backups/linkding-config-$(date +%F).tar.gz -C /srv/linkding compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/linkding/backups/
```

You should see: `Backup created at /backups/linkding-...zip`, then two files listed, both a few
kilobytes on a fresh install. Nothing goes offline: `full_backup` is upstream's own command and
it copies the database through SQLite's own backup API rather than reading the file, which is
what makes it safe on a running server.

If you do not: `no such option: full_backup` means an older image than 1.45.0 is running, so
check the version in the health output from step 7. Do not work around this by copying
`db.sqlite3` with `cp`: upstream warns plainly that copying that file is not transaction safe
and can leave you with a corrupted database that still opens.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not
the server:

```bash
mkdir -p ~/backups/linkding
scp vps:/srv/linkding/backups/* ~/backups/linkding/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/linkding/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:`
prefix only means something on your own machine, where the `vps` alias Prompt Zero created
lives.

Now prove the restore, today, while the only thing at risk is one test bookmark:

```bash
cd /srv/linkding
docker compose down
sudo rm -rf /srv/linkding/data
sudo install -d -m 750 -o 33 -g 33 /srv/linkding/data
docker compose run --rm linkding python -m zipfile -e /backups/linkding-$(date +%F).zip /etc/linkding/data
docker compose up -d
sleep 20
curl -sS https://<DOMAIN>/health; echo
docker compose exec -T linkding python manage.py shell -c "from bookmarks.models import Bookmark; print(Bookmark.objects.count())"
```

You should see: `{"version": "1.45.0", "status": "healthy"}`, then the same bookmark count as
before, from a data folder that was deleted and rebuilt out of the zip.

If you do not: a count of `0` means the unzip landed somewhere else, so run `docker compose
exec -T linkding ls -la /etc/linkding/data` and check that `db.sqlite3` is there. One thing
the zip does not contain is `secretkey.txt`, which the container writes again on start, so
every browser session is signed out after a restore and the same password signs you back in.
Those five commands are the whole disaster plan.

## 9. Updating later

New versions are listed at https://github.com/sissbruecker/linkding/releases. Take both backup
artifacts first, then edit the `image:` line in /srv/linkding/compose.yml to the new tag and
its digest.

```bash
cd /srv/linkding
docker compose pull
docker compose up -d
docker compose logs --tail 30 linkding
```

You should see: migration output, then the server starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Then re-run the
`/health` check from step 7 before you call the update done, and check that the `version` field
in the response is the one you meant to install, because a `docker compose pull` that quietly
failed leaves the old image running and a healthy answer coming from it.

## 10. What will probably go wrong

The login form will look broken before anything else does. linkding speaks plain http to
Caddy, so the framework behind it believes the request arrived over http, while your browser
sends an `Origin` header that says https, and a mismatch there answers the login POST with a
bare `403 CSRF verification failed` page and no other explanation. I lost ten minutes to that
screen convinced the password was wrong. The `LD_CSRF_TRUSTED_ORIGINS` line in step 3 is what
stops it, which means it has to carry the exact scheme and hostname you type, with no trailing
path and no port. If you ever move this install to a different hostname, that line moves with
it or the login stops working again.

## 11. Out of scope

- Do not switch to the `-plus` image. It bundles a Chromium to snapshot pages as HTML, upstream
  asks for at least 1 GB of RAM before it will behave, and step 1 sized the box for the plain
  one.
- Do not set `LD_DB_ENGINE` to `postgres`. SQLite is the choice here, and it is what the backup
  command in step 8 knows how to snapshot.
- Do not enable `LD_ENABLE_AUTH_PROXY` or `LD_ENABLE_OIDC`. Both hand authentication to an
  identity provider that is a separate install on a separate hostname.
- Do not configure SMTP. linkding sends no mail, so there is nothing for it to do.

304 lines · 14,995 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install linkding 1.45.0 under ~/selfhost/linkding, answering at http://localhost:8118.

## 1. Preflight

Say this to the user before step 2 runs; it decides whether they want this install.
linkding answers at http://localhost:8118, which means this computer and nothing else: not the
phone they read on, not a second laptop, and the browser extension works only in a browser
running here. What they get is a private, fast, tag-first index of their own links, on one
machine.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. linkding needs 512 MB of RAM available and
5 GB free on the home disk, and the image publishes amd64, arm64 and armv7. Every branch prints
free memory, so one floor covers all three; on macOS and Windows it is the host's, out of which
Docker Desktop's virtual machine takes its allocation. If available RAM is under 512 MB or free
disk is under 5 GB, print both numbers and stop. Do not install and hope.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/linkding/backups
ls -la ~/selfhost/linkding
```

Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder on purpose: the
image chowns its own data directory to the `www-data` uid on every start, so step 5 keeps the
database in a volume Docker manages. `backups` is a real folder because those are the files the
user has to see and copy.

## 4. Secrets

One secret: the password for the only account this install will have. Generate it here, print
it nowhere, and keep it out of your summary and any log.

```bash
umask 077
cat > ~/selfhost/linkding/.env <<EOF
LD_SUPERUSER_NAME=admin
LD_SUPERUSER_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 ~/selfhost/linkding/.env
umask 022
ls -l ~/selfhost/linkding/.env
```

Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so these lines run the
same on all three systems. Hex rather than base64: Compose reads this same file for
interpolation and would expand a `$` in a value, and the user pastes this password into a
login form.

Upstream documents these two options as an initial superuser created once during container
start-up, and one created with no password cannot use the login form. Tell the user their
password is in ~/selfhost/linkding/.env, that they read it with `grep LD_SUPERUSER_PASSWORD
~/selfhost/linkding/.env`, and that it belongs in their password manager before step 7.
linkding has no sign-up page and no password-reset mail, so those are the only two places it
exists.

On Windows those mode bits are advisory: NTFS does not enforce them, and the real boundary is
the user's own Windows account.

## 5. compose.yml

```bash
cat > ~/selfhost/linkding/compose.yml <<'EOF'
# linkding · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ....... https://linkding.link/installation/
#   options reference .... https://linkding.link/options/
#   backups .............. https://linkding.link/backups/
#   archiving ............ https://linkding.link/archiving/
#
# One service on the computer you are sitting at, with paths relative to
# ~/selfhost/linkding/ so one file works on macOS, Linux and Windows. The data
# folder is a named volume rather than a bind mount because the image chowns
# /etc/linkding/data to the www-data uid on every start, and a home-directory
# folder would come back owned by a uid the reader is not; backups stay a
# relative bind so the archives show up in Finder or Explorer. This is the
# plain image, not the -plus variant, which adds Chromium for HTML snapshots
# and wants at least 1 GB of RAM. Digest read from Docker Hub on 2026-08-06;
# the image publishes amd64, arm64 and armv7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  linkding:
    image: sissbruecker/linkding:1.45.0@sha256:61b2eb9eed8e5772a473fb7f1f8923e046cb8cbbeb50e88150afd5ff287d4060
    container_name: linkding
    restart: unless-stopped
    # LD_SUPERUSER_NAME and LD_SUPERUSER_PASSWORD, mode 600, never printed.
    env_file: ./.env
    environment:
      # SQLite is upstream's default and the choice here. Nothing to operate.
      LD_DB_ENGINE: sqlite
      # Left on (the default). The worker files Wayback Machine snapshots
      # only for an account whose owner turned that on.
      LD_DISABLE_BACKGROUND_TASKS: "False"
    volumes:
      # db.sqlite3, plus the assets, favicons and previews folders.
      - linkding-data:/etc/linkding/data
      # Where the backup step writes its zip.
      - ./backups:/backups
    ports:
      # Loopback only: no other device on the wifi can reach 8118.
      - "127.0.0.1:8118:9090"

volumes:
  linkding-data:
EOF
cd ~/selfhost/linkding && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one port, one named volume.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule, and each is a decision. There is
no hostname, so nothing to resolve and nothing to wait for. A certificate attests a public name
and nothing here has one; browsers treat http://localhost as a secure context anyway, so pages
needing crypto work. Nothing is published beyond loopback, so no port needs closing.

8118 is bound to 127.0.0.1, this computer only: not the user's phone, not a laptop on the same
wifi, not anyone on the internet. Confirm it:

```bash
grep -n '127.0.0.1' ~/selfhost/linkding/compose.yml
```

Assert: one line, `- "127.0.0.1:8118:9090"`. It is also why step 4 needs no
`LD_CSRF_TRUSTED_ORIGINS` line: the browser's address and linkding's are the same one.

## 7. Start and verify

The container generates its secret key, migrates the database, turns on WAL mode and creates
the account named in `LD_SUPERUSER_NAME`, all on the way up.

```bash
cd ~/selfhost/linkding
docker compose pull
docker compose up -d
for i in $(seq 1 24); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8118/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS http://localhost:8118/health; echo
curl -sS http://localhost:8118/login/ | grep -o 'id="main-heading">Login'
docker compose exec -T linkding python manage.py shell -c "from django.contrib.auth import get_user_model; print(get_user_model().objects.count())"
```

Assert all four, and print what you received for each: the loop ends on `200`; the health
response reads `{"version": "1.45.0", "status": "healthy"}`, where `status` is the assert and
the version confirms which image is running; the grep prints `id="main-heading">Login`, the
heading on the first screen; the last command prints `1`, the account step 4 created. If any
of the four misses, stop, run `docker compose logs --tail 40 linkding`, and name the likely
cause: a count of `0` means the `.env` was written after the container first started, so
`docker compose down` then `up -d`. If `port is already allocated` came back, find what holds
8118 with `lsof -nP -iTCP:8118 -sTCP:LISTEN` (`netstat -ano | findstr :8118` on Windows) and
stop until the user frees it. A running container is not success.

STOP: tell the user to read their password with `grep LD_SUPERUSER_PASSWORD
~/selfhost/linkding/.env`, put it in their password manager, open http://localhost:8118, log
in as `admin`, save one bookmark, and wait. Do not continue until they confirm. That address
redirects to /login/ and shows the heading `Login` over `Username` and `Password` boxes and a
`Login` button, with the tab reading `Login - Linkding`. There is no register link and no
route behind one, so the count of `1` above is the whole access-control surface.

```bash
docker compose exec -T linkding python manage.py shell -c "from bookmarks.models import Bookmark; print(Bookmark.objects.count())"
```

Assert: a number greater than 0. Print it. That is a link that went through the browser, the
login form and into the database: the whole product working end to end.

## 8. First backup and restore

Two artifacts. The zip holds the bookmarks: `db.sqlite3` plus the `assets`, `favicons` and
`previews` folders. The config archive holds the two files that rebuild the service around it.

```bash
cd ~/selfhost/linkding
docker compose exec -T linkding python manage.py full_backup /backups/linkding-$(date +%F).zip
tar -C ~/selfhost/linkding -czf ~/selfhost/linkding/backups/linkding-config-$(date +%F).tar.gz compose.yml .env
ls -lh ~/selfhost/linkding/backups/
```

Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped:
`full_backup` is upstream's own command and copies the database through SQLite's backup API,
which upstream asks for because a plain `cp` of `db.sqlite3` is not transaction safe. On Linux
the container writes that zip as root: the user can copy it, and needs `sudo` to delete it.

Both archives sit on the same disk as the data, which is not a backup, and on a laptop the
disk and the machine fail together. Ask the user for a destination that leaves this computer,
a folder their sync service watches or a USB stick, and copy both there with `cp`. In Git Bash
a Windows drive is written `/d/Backups`, not `D:\Backups`. Assert: the user confirms both
filenames are listed there. If they have neither, say plainly that this install has no backup.

To restore: `cd ~/selfhost/linkding`, untar the config archive there first if compose.yml or
.env were lost, then `docker compose down -v`, the one place `-v` belongs because it drops the
old database volume on purpose. Unpack the zip into the fresh volume with a one-off container
that has the same mounts and no port, `docker compose run --rm linkding python -m zipfile -e
/backups/linkding-$(date +%F).zip /etc/linkding/data` with the date of the archive being
restored, then `docker compose up -d`. The zip has no `secretkey.txt`, which the container
writes again on start: every session is signed out and the same password signs back in. Those
four commands are the whole disaster plan.

## 9. Updating later

New versions are listed at https://github.com/sissbruecker/linkding/releases. Take both backups
first, then edit the image line in ~/selfhost/linkding/compose.yml to the new tag and digest:

```bash
cd ~/selfhost/linkding
docker compose pull
docker compose up -d
docker compose logs --tail 30 linkding
```

linkding migrates its own database on the way up. Watch that log until it settles, then re-run
step 7's health check.

## 10. What will probably go wrong

I rebooted this machine, opened the bookmark I had made for my own bookmarks, and got a
connection refused that read like a lost database. It was not: Docker Desktop had not started
with the session, so nothing was listening on 8118. `restart: unless-stopped` acts only once
the Docker daemon is up. Turn on Docker Desktop's start-at-login setting, then after a reboot
run `cd ~/selfhost/linkding && docker compose up -d` before concluding anything is wrong.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8118 to 0.0.0.0 so a phone on the same wifi can reach it. That puts everything
  the user reads behind one form on every network they join.
- Do not switch to the `-plus` image, and do not set `LD_DB_ENGINE` to `postgres`. The first
  bundles a Chromium that upstream wants 1 GB of RAM for; the second is another container and a
  database step 8's backup command cannot snapshot.
compose.local.ymlthe services, pinned · local layout43 lines

authored from upstream docs, never pasted · 2,038 bytes

# linkding · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ....... https://linkding.link/installation/
#   options reference .... https://linkding.link/options/
#   backups .............. https://linkding.link/backups/
#   archiving ............ https://linkding.link/archiving/
#
# One service on the computer you are sitting at, with paths relative to
# ~/selfhost/linkding/ so one file works on macOS, Linux and Windows. The data
# folder is a named volume rather than a bind mount because the image chowns
# /etc/linkding/data to the www-data uid on every start, and a home-directory
# folder would come back owned by a uid the reader is not; backups stay a
# relative bind so the archives show up in Finder or Explorer. This is the
# plain image, not the -plus variant, which adds Chromium for HTML snapshots
# and wants at least 1 GB of RAM. Digest read from Docker Hub on 2026-08-06;
# the image publishes amd64, arm64 and armv7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  linkding:
    image: sissbruecker/linkding:1.45.0@sha256:61b2eb9eed8e5772a473fb7f1f8923e046cb8cbbeb50e88150afd5ff287d4060
    container_name: linkding
    restart: unless-stopped
    # LD_SUPERUSER_NAME and LD_SUPERUSER_PASSWORD, mode 600, never printed.
    env_file: ./.env
    environment:
      # SQLite is upstream's default and the choice here. Nothing to operate.
      LD_DB_ENGINE: sqlite
      # Left on (the default). The worker files Wayback Machine snapshots
      # only for an account whose owner turned that on.
      LD_DISABLE_BACKGROUND_TASKS: "False"
    volumes:
      # db.sqlite3, plus the assets, favicons and previews folders.
      - linkding-data:/etc/linkding/data
      # Where the backup step writes its zip.
      - ./backups:/backups
    ports:
      # Loopback only: no other device on the wifi can reach 8118.
      - "127.0.0.1:8118:9090"

volumes:
  linkding-data:

agent-readable mirror: /self-host/pinboard.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned46 lines

authored from upstream docs, never pasted · 2,386 bytes

# linkding · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ....... https://linkding.link/installation/
#   options reference .... https://linkding.link/options/
#   backups .............. https://linkding.link/backups/
#   archiving ............ https://linkding.link/archiving/
#   proxy and CSRF ....... https://linkding.link/troubleshooting/
#
# One service. Bookmarks, tags and notes are rows in a SQLite database under
# /etc/linkding/data, so there is no second container and no database password
# anywhere. This is the plain image, not the -plus variant, which adds Chromium
# to snapshot pages as HTML and wants at least 1 GB of RAM. The container starts
# as root, migrates the database, then hands the web server to www-data and
# chowns /etc/linkding/data to that uid (33) on every start, which is why the
# install creates the host folder owned by 33. The image carries its own
# HEALTHCHECK against /health, so `docker compose ps` reports health with
# nothing declared here. Tag and digest read from Docker Hub on 2026-08-06; the
# image publishes amd64, arm64 and armv7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  linkding:
    image: sissbruecker/linkding:1.45.0@sha256:61b2eb9eed8e5772a473fb7f1f8923e046cb8cbbeb50e88150afd5ff287d4060
    container_name: linkding
    restart: unless-stopped
    # LD_SUPERUSER_NAME, LD_SUPERUSER_PASSWORD and LD_CSRF_TRUSTED_ORIGINS,
    # mode 600, never printed.
    env_file: /srv/linkding/.env
    environment:
      # SQLite is upstream's default and the choice here. Nothing to operate.
      LD_DB_ENGINE: sqlite
      # Left on (the default). The worker files Wayback Machine snapshots
      # only for an account whose owner turned that on.
      LD_DISABLE_BACKGROUND_TASKS: "False"
      # Every request arrives from Caddy on 127.0.0.1, so without this the
      # access log records the proxy rather than the browser.
      LD_LOG_X_FORWARDED_FOR: "true"
    volumes:
      # db.sqlite3, plus the assets, favicons and previews folders.
      - /srv/linkding/data:/etc/linkding/data
      # Where the backup step writes its zip.
      - /srv/linkding/backups:/backups
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8118.
      - "127.0.0.1:8118:9090"
Caddyfilethe hostname and TLS32 lines

authored from upstream docs, never pasted · 1,241 bytes

# linkding · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://linkding.link/installation/,
# https://linkding.link/troubleshooting/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. Upstream states
# that Caddy passes the Host header through unchanged and needs no extra
# directive for it. The same hostname is also LD_CSRF_TRUSTED_ORIGINS in .env,
# because linkding speaks plain http behind this proxy while the browser sends
# an https Origin, and Django compares the two.

<DOMAIN> {
	# The bookmark list and the JSON API compress well.
	encode zstd gzip

	# linkding sets its own X-Frame-Options: DENY, so this block does not
	# repeat it and must not weaken it. HSTS is here because every request to
	# this host carries a session cookie.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8118 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8118
}
install.shthe same install, no agent158 lines

authored from upstream docs, never pasted · 7,143 bytes

#!/usr/bin/env bash
# linkding · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=links.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://linkding.link/installation/
#   https://linkding.link/options/
#   https://linkding.link/backups/
#   https://linkding.link/troubleshooting/
#
# One secret is generated here, on this machine: the password for the single
# account this install creates. It goes into /srv/linkding/.env with mode 600
# and it is never printed.
#
# DOMAIN_HOST is written twice, into the Caddy site block and into
# LD_CSRF_TRUSTED_ORIGINS. The second one is what makes the login form work
# behind a proxy that terminates TLS, so a hostname change means editing both.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/linkding}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
SUPERUSER_NAME="${SUPERUSER_NAME:-admin}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. links.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 512 ] || die "only ${avail_mb} MB of RAM available; this install wants 512 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# data belongs to uid 33 because the container hands its web server to
# www-data and chowns that folder to uid 33 on every start.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 750 -o 33 -g 33 "$APP_DIR/data"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the one secret, on the server -------------------------------
#
# Hex rather than base64: Docker Compose reads this same file for variable
# interpolation, so a $ inside a value would be expanded, and a human has to
# paste this one into a login form. Read it later with
#   sudo grep LD_SUPERUSER_PASSWORD /srv/linkding/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		LD_SUPERUSER_NAME=${SUPERUSER_NAME}
		LD_SUPERUSER_PASSWORD=$(openssl rand -hex 24)
		LD_CSRF_TRUSTED_ORIGINS=https://${DOMAIN_HOST}
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-linkding"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and 8118 is not one of them -------------------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8118 stays closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# The container writes its secret key, runs the migrations, turns on SQLite WAL
# mode and creates the account named in LD_SUPERUSER_NAME, all on the way up.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/health"
for _ in $(seq 1 24); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/health" || true)"
	[ "$code" = "200" ] && break
	sleep 5
done
[ "${code:-}" = "200" ] || die "/health answered ${code:-nothing}. Check: docker compose logs --tail 40 linkding"

curl -sS "https://${DOMAIN_HOST}/health" | grep -q '"status": "healthy"' \
	|| die "/health answered 200 without a healthy status. Check: docker compose logs --tail 40 linkding"

# The first screen is the login form, and it is the only door in.
curl -sS "https://${DOMAIN_HOST}/login/" | grep -q 'id="main-heading">Login' \
	|| die "the login page did not render its heading. Caddy may not be reaching the container."

# Exactly one account exists, the one the .env above created. linkding ships no
# sign-up page, so this number is the whole access-control surface.
users="$(docker compose exec -T linkding python manage.py shell \
	-c "from django.contrib.auth import get_user_model; print(get_user_model().objects.count())" \
	| tr -d '\r' | tail -1)"
[ "$users" = "1" ] || die "expected 1 account, found ${users}. Check that .env existed before the first start."

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T linkding python manage.py full_backup "/backups/linkding-${STAMP}.zip" >/dev/null
sudo tar -czf "$APP_DIR/backups/linkding-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/linkding-${STAMP}.zip" ] || die "the bookmark backup is empty"

cat <<-DONE

	linkding is answering at https://${DOMAIN_HOST}/

	  1. The first screen is a login form. Sign in as ${SUPERUSER_NAME} with the
	     password in $APP_DIR/.env, mode 600. Read it with
	       sudo grep LD_SUPERUSER_PASSWORD $APP_DIR/.env
	     and put it in your password manager. It was not printed here, there is
	     no sign-up page, and no password-reset mail is ever sent. If you lose
	     it, the way back in is
	       cd $APP_DIR && docker compose exec linkding python manage.py changepassword ${SUPERUSER_NAME}
	  2. This is the plain image, so pages are not archived as HTML here. The
	     Wayback Machine integration exists but stays off until you turn it on
	     in your own profile settings, and so do favicons and preview images.
	  3. First backup written to $APP_DIR/backups: a zip of the bookmarks and a
	     config archive. They are on the same disk as the data, which is not a
	     backup. Copy them somewhere else tonight.
	  4. The hostname is in two places, the Caddy site block and
	     LD_CSRF_TRUSTED_ORIGINS in .env. Change one without the other and the
	     login form starts answering 403.

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Pinboard.

  • You own the backups. Everything is one SQLite file plus three folders under /srv/linkding/data, and the safe way to copy it is the full_backup command, not cp: upstream says plainly that copying the database file while it is open is not transaction safe.
  • No page archiving in this install. The image that snapshots pages as HTML carries a Chromium and wants at least 1 GB of RAM, so this runs the plain one. You keep the link, the title, the tags and your notes, and the page itself stays wherever it lives, which is the exact thing Pinboard's archival tier sells.
  • Nothing phones anywhere by default. Wayback Machine snapshots, favicons and preview images are all off until you switch them on in your own profile, and turning favicons on starts sending every bookmarked hostname to a third-party icon service.
  • There is no sign-up page. Accounts exist only because you created them, from the command line or the admin app, so there is no invitation flow, no password-reset mail, and losing the password means running one command on the server to set a new one.
  • Nobody is watching the domain for you. Pinboard's yearly fee buys one person's attention to a site that has stayed up since 2009; here the uptime, the certificate and the disk are yours.

Where this came from

“The linkding Docker image does not provide an initial user, so you have to create one after setting up an installation.”

  • The image ships with no user at all, so the install creates the first one from the LD_SUPERUSER_NAME and LD_SUPERUSER_PASSWORD options during container start-up. source
  • A superuser created with LD_SUPERUSER_PASSWORD left undefined gets no usable password and cannot authenticate through the login form. source
  • Archiving pages as HTML snapshots is only available in the latest-plus image, which bundles Chromium and needs at least 1 GB of RAM to run it. source
  • Upstream documents manage.py full_backup as the way to snapshot the data folder, and cautions that copying the SQLite file directly is not transaction safe and may corrupt it. source
  • A login that answers 403 CSRF verification failed behind a reverse proxy is fixed by setting LD_CSRF_TRUSTED_ORIGINS to the URL the instance is reached at. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Pinboard?

    Not Pinboard itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is linkding. A tag-first bookmark manager in one container: save the link, type the tags, find it again in a year. The install is one command: one container behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 8 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces Pinboard?

    linkding. A tag-first bookmark manager in one container: save the link, type the tags, find it again in a year. The one that keeps the shape of the thing you are leaving. Open the page, paste a URL, type tags, close the page: no collections to design, no workspace to set up, and a tag list and a search box to get back to it later. It is one container with a SQLite file behind it, its settings page imports the HTML bookmark file that browsers and bookmarking services hand you on the way out, and it does not try to become a read-later app while you are not looking. What it does not do is keep a copy of the page, which is what the archival tier was for. Also evaluated and ranked below it: Linkwarden. The prompt on this page installs linkding only. linkding is MIT-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Pinboard?

    512 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. linkding itself is free and MIT-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Pinboard Standard, $1.83/mo — $21.96 a year.

  • How hard is it really?

    ONE COMMAND — under 10 minutes. The rule that produced that verdict: one container, no database, no outside integration, at most one secret. Nothing to negotiate with anyone else, nothing to back up separately, at most one secret to generate. This is the case where the compose file honestly is the whole install. The tier is derived from seven countable facts about the linkding install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run linkding on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same linkding on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: On your own computer linkding answers only at http://localhost:8118, so the phone you actually read on cannot reach it and the browser extension works only in a browser running on this machine. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.