Can I self-host Raindrop.io?
YES · ONE EVENING— setup effort 2 of 4YES — it's called Linkwarden. It takes one prompt, a 2048 MB VPS, and about 90 minutes. That is $3 a month you stop paying Raindrop.io — $36 a year on the Pro plan.
Why people pay for Raindrop.io
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.
Raindrop is the rare paid tool that is cheap enough to forget about, and it earns that by keeping permanent copies of the pages you saved, searching inside them, and syncing to apps on every platform without ever asking you to think about it. The free tier already does unlimited bookmarks, so Pro is bought for the archive and the search rather than for capacity.
| Plan | List price | What it buys |
|---|---|---|
| Free | free | Unlimited bookmarks and collections on unlimited devices, without permanent copies or full-text search. |
| Prothe plan this page prices against | $3/mo | Adds full-text search, permanent copies of saved pages, more upload space, cloud backup and duplicate cleanup. Annual billing is the advertised path, at roughly $28 a year. |
Vendor list prices in USD, read from the pricing page on 2026-08-05 · confidence: low
Replaced by Linkwarden
One project, named before the prompt, so you know what you are about to install.
Bookmarks that keep their own copy of the page, so a dead link is still readable years later.
The only self-hosted option that matches the reason people pay Raindrop: it keeps its own copy of every page it saves, as a screenshot, a PDF and a single-file HTML archive, so a dead link is still readable years later. It costs a PostgreSQL you operate and a headless browser that wants real memory, and searching inside those archives needs MeiliSearch, which our install leaves out.
The swap
You'd run
Linkwarden
ONE EVENING · ~90 min to running · 2048 MB RAM
Raindrop.io Pro · vendor list price · checked 2026-08-05 · source · confidence: low
Before you start
- RAM floor
- 2048 MBfloor from upstream docs — not measured by us yet
- Disk
- 20 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
- Time budget
- ~90 min1–3 hours, through the first backup
The prompt
One prompt, assuming Prompt Zero is done. It installs Linkwarden — read it before you paste it, which is the whole reason it is on the page instead of behind a download.
294 lines · 12,255 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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 Linkwarden 2.16.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. Linkwarden needs 2048 MB of RAM
available and 20 GB free on /srv, because preserving a page runs a headless Chromium and
each saved link can leave a screenshot, a PDF and an HTML copy behind. Both images publish
amd64 and arm64. Measure all four first:
```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 2048 MB or free disk is under 20 GB, print both numbers and
stop: the failure mode here is the OOM killer arriving mid-import, which looks random and
is not. If `dig +short` prints nothing, print that and stop.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/linkwarden /srv/linkwarden/backups /srv/linkwarden/data
sudo install -d -m 700 /srv/linkwarden/postgres
ls -la /srv/linkwarden
```
Assert: `ls -la` shows `backups`, `data` and `postgres` at mode `700`. The PostgreSQL
image chowns its own data directory on first start, so leave that one owned by root.
## 3. Secrets
Two secrets: the PostgreSQL password and the NextAuth signing secret. Generate both on the
server. Do not print either, do not repeat them in your summary, and do not put them in
any log line. Hex rather than base64, because the database password ends up inside a
connection URL where the base64 alphabet would need escaping.
```bash
umask 077
cat > /srv/linkwarden/.env <<EOF
NEXTAUTH_URL=https://<DOMAIN>/api/v1/auth
NEXT_PUBLIC_DISABLE_REGISTRATION=false
NEXTAUTH_SECRET=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/linkwarden/.env
umask 022
ls -l /srv/linkwarden/.env
```
Assert: the file exists with mode `-rw-------`. `NEXTAUTH_URL` has to carry the
`/api/v1/auth` suffix, which upstream documents as a requirement, and registration is open
on purpose until step 7 closes it. Tell the user
`sudo grep -E 'POSTGRES_PASSWORD|NEXTAUTH_SECRET' /srv/linkwarden/.env` reads both values
and that they belong in a password manager now.
## 4. compose.yml
```bash
cat > /srv/linkwarden/compose.yml <<'EOF'
# Linkwarden · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# setup and env vars . https://docs.linkwarden.app/self-hosting/setup
# variable reference . https://docs.linkwarden.app/self-hosting/environment-variables
# reverse proxy ...... https://docs.linkwarden.app/self-hosting/reverse-proxy
#
# Two services: the app, and the PostgreSQL it needs. MeiliSearch is deliberately
# absent, because Linkwarden only starts its search client when MEILI_MASTER_KEY
# is set, so leaving it out costs a container and a secret. Tags and digests were
# read from the registries on 2026-08-05; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
container_name: linkwarden-db
restart: unless-stopped
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- /srv/linkwarden/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
linkwarden:
image: ghcr.io/linkwarden/linkwarden:v2.16.0@sha256:d805877fb707d160b809027c302f84cfba11a248d7fdc12de90b4791f98e6b55
container_name: linkwarden
restart: unless-stopped
env_file: /srv/linkwarden/.env
environment:
# Built here, not in .env: compose expands ${...} in this file.
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres
volumes:
# Archives, screenshots, PDFs, uploads. STORAGE_FOLDER defaults to `data`
# and the image's working directory is /data, hence /data/data.
- /srv/linkwarden/data:/data/data
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8085.
- "127.0.0.1:8085:3000"
depends_on:
postgres:
condition: service_healthy
EOF
cd /srv/linkwarden && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Two services, and only the app publishes a port: 8085 on
loopback. MeiliSearch is absent on purpose, because Linkwarden only starts its search
client when `MEILI_MASTER_KEY` is set.
## 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-linkwarden
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Linkwarden · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.linkwarden.app/self-hosting/reverse-proxy 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 documents nginx
# only, and every forwarding header their example sets by hand is one Caddy sets
# on its own, which is why there is no header_up line below.
<DOMAIN> {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
-Server
}
# 8085 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:8085
}
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-linkwarden, reload, and report what it objected to. Caddy
requests the certificate on the first request and renews it without a cron job.
## 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. 8085 stays closed because it is bound to 127.0.0.1, and 5432 stays
closed because compose never publishes it: the database has no host port to firewall.
Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and 443/udp, and
no rule for 8085 or 5432.
## 7. Start and verify
The first boot is slow: Prisma applies the whole schema before Next.js answers anything,
so a 502 for the first few minutes is normal.
```bash
cd /srv/linkwarden
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sSL -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sSL https://<DOMAIN>/ | grep -ci 'linkwarden'
```
Assert: the loop ends printing `200`, and the second command prints a number greater than
`0`, because `Linkwarden` appears in the served document. Print what you actually received
for both. If the loop runs out, stop, run `docker compose logs --tail 50 linkwarden` and
`docker compose logs --tail 20 postgres`, and say which earlier step is the likely cause:
a database container that never reports healthy points at step 2, and a 502 that never
clears points at the migration in the app log. The first screen at https://<DOMAIN> is a
login form with fields for a username and a password, and a link to create an account.
STOP: tell the user to open https://<DOMAIN>/register, create their account, and wait. Do
not continue until they confirm they can sign in.
Once they confirm, close registration. A restart is not enough: upstream documents that
containers have to be recreated for a changed `.env` to take effect.
```bash
cd /srv/linkwarden
sed -i 's/^NEXT_PUBLIC_DISABLE_REGISTRATION=false$/NEXT_PUBLIC_DISABLE_REGISTRATION=true/' /srv/linkwarden/.env
grep NEXT_PUBLIC_DISABLE_REGISTRATION /srv/linkwarden/.env
docker compose down
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sSL -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
```
STOP: tell the user to sign out, try to create a second account at
https://<DOMAIN>/register, and confirm it is refused. Do not continue until they confirm
the refusal.
Assert: the grep printed `true`, the loop printed `200`, and the user confirmed the second
registration was refused. All three. A running container is not success.
## 8. First backup and restore
Two artifacts, because there are two kinds of state: the database holds the links, tags
and account, and the data directory holds archived copies no dump contains.
```bash
cd /srv/linkwarden
docker compose exec -T postgres pg_dump -U postgres -d postgres | gzip > /srv/linkwarden/backups/linkwarden-db-$(date +%F).sql.gz
sudo tar -C /srv/linkwarden -czf /srv/linkwarden/backups/linkwarden-files-$(date +%F).tar.gz data .env
ls -lh /srv/linkwarden/backups/
```
Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped,
because `pg_dump` snapshots a running database consistently, which is why it is dumped
rather than copied off disk. A backup on the same disk is not a backup, so run this from
the user's machine:
```bash
mkdir -p ~/backups/linkwarden
scp vps:/srv/linkwarden/backups/* ~/backups/linkwarden/
```
To restore: `docker compose down`,
`sudo rm -rf /srv/linkwarden/data /srv/linkwarden/postgres`, recreate both as in step 2,
`docker compose up -d postgres`, feed the dump back by piping `gunzip -c` on the `.sql.gz`
into `docker compose exec -T postgres psql -U postgres -d postgres`, untar the file
archive into /srv/linkwarden, then `docker compose up -d`. Tell the user the dump alone
gives links with dead previews and the archive alone gives files nothing points at. Both
or neither.
## 9. Updating later
New versions are listed at https://github.com/linkwarden/linkwarden/releases. Take both
backup artifacts first, then edit the image line in /srv/linkwarden/compose.yml to the new
tag and its digest:
```bash
cd /srv/linkwarden
docker compose pull
docker compose up -d
docker compose logs --tail 30 linkwarden
```
Prisma runs new migrations on the way up, so watch that log until it stops moving. A
database from a newer version will not load into an older image, which is why the backup
goes first.
## 10. What will probably go wrong
The first four minutes. `docker compose up -d` returned straight away, both containers
showed as running, and the hostname answered 502 for long enough that I reached for the
rollback. Nothing was broken: Prisma was applying the schema, and Next.js answers nothing
until that finishes. The tell is `docker compose logs -f linkwarden`, where the migration
lines visibly progress. If the log is moving, wait. If it has been silent for two minutes
and the answer is still 502, look at the database container.
## 11. Out of scope
- Do not add MeiliSearch. It is a third container and a third secret, and search over the
text of archived pages is not what this prompt installs.
- Do not configure SMTP. Email verification and password reset stay off, which is
survivable on a single-user install.
- Do not wire up an SSO or OAuth provider. Credentials login is on, and it is the only
path here.
- Do not set an AI tagging key. Automatic tagging sends page text to a third party, and
that is the user's decision.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 Linkwarden 2.16.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. This one is two containers and two secrets, so set aside
an evening rather than a coffee break.
## 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 `2048` MB available, at least `20` G free, `amd64` or `arm64`,
and your server's IP on the last line.
If you do not: stop here rather than continuing on a smaller box. Preserving a page runs a
headless Chromium, and each saved link can leave a screenshot, a PDF and a single-file
HTML copy behind. On a 1 GB machine the install works and then the OOM killer takes the
archiver out in the middle of your first import, which looks random and is not. An empty
last line means the A record does not exist yet: add it, wait a minute, run
`dig +short <DOMAIN>` again.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/linkwarden /srv/linkwarden/backups /srv/linkwarden/data
sudo install -d -m 700 /srv/linkwarden/postgres
ls -la /srv/linkwarden
```
You should see: `backups`, `data` and `postgres`, with `postgres` at mode `drwx------` and
owned by root.
If you do not: leave `postgres` owned by root on purpose. The PostgreSQL image chowns its
own data directory the first time it starts, and a directory you have already chowned to
yourself makes it refuse with a message about ownership that mentions nothing helpful.
## 3. Secrets
Two secrets: the PostgreSQL password and the NextAuth signing secret. Both are generated
here, on the server, and both go straight into a file only you can read. Hex rather than
base64, because the database password ends up inside a connection URL where the base64
alphabet would need escaping.
```bash
umask 077
cat > /srv/linkwarden/.env <<EOF
NEXTAUTH_URL=https://<DOMAIN>/api/v1/auth
NEXT_PUBLIC_DISABLE_REGISTRATION=false
NEXTAUTH_SECRET=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/linkwarden/.env
umask 022
ls -l /srv/linkwarden/.env
```
You should see: mode `-rw-------`, your own username twice, and the path. Read both values
once with `sudo grep -E 'POSTGRES_PASSWORD|NEXTAUTH_SECRET' /srv/linkwarden/.env` and put
them in your password manager.
If you do not: replace `<DOMAIN>` in the first line with your real hostname before you
paste, and keep the `/api/v1/auth` suffix exactly as it is. Upstream documents that suffix
as a requirement, and login fails in a way that looks like a wrong password if it is
missing.
Do not paste the contents of that file, either secret, or any command output containing
them into this chat window. Nothing in the rest of this guide needs them, and once they
are in a transcript they are somebody else's copy.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/linkwarden/compose.yml <<'EOF'
# Linkwarden · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# setup and env vars . https://docs.linkwarden.app/self-hosting/setup
# variable reference . https://docs.linkwarden.app/self-hosting/environment-variables
# reverse proxy ...... https://docs.linkwarden.app/self-hosting/reverse-proxy
#
# Two services: the app, and the PostgreSQL it needs. MeiliSearch is deliberately
# absent, because Linkwarden only starts its search client when MEILI_MASTER_KEY
# is set, so leaving it out costs a container and a secret. Tags and digests were
# read from the registries on 2026-08-05; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
container_name: linkwarden-db
restart: unless-stopped
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- /srv/linkwarden/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
linkwarden:
image: ghcr.io/linkwarden/linkwarden:v2.16.0@sha256:d805877fb707d160b809027c302f84cfba11a248d7fdc12de90b4791f98e6b55
container_name: linkwarden
restart: unless-stopped
env_file: /srv/linkwarden/.env
environment:
# Built here, not in .env: compose expands ${...} in this file.
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres
volumes:
# Archives, screenshots, PDFs, uploads. STORAGE_FOLDER defaults to `data`
# and the image's working directory is /data, hence /data/data.
- /srv/linkwarden/data:/data/data
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8085.
- "127.0.0.1:8085:3000"
depends_on:
postgres:
condition: service_healthy
EOF
cd /srv/linkwarden && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `env file /srv/linkwarden/.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/linkwarden/compose.yml` and paste the block again in one go.
Note what is not in this file: MeiliSearch. Linkwarden only starts its search client when
`MEILI_MASTER_KEY` is set, so leaving it out costs a container and a secret rather than
breaking anything.
## 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-linkwarden
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Linkwarden · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.linkwarden.app/self-hosting/reverse-proxy 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 documents nginx
# only, and every forwarding header their example sets by hand is one Caddy sets
# on its own, which is why there is no header_up line below.
<DOMAIN> {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
-Server
}
# 8085 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:8085
}
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-linkwarden /etc/caddy/Caddyfile`,
reload, and paste again. Upstream's reverse proxy page documents nginx and sets four
forwarding headers by hand; Caddy sets all of them itself, which is why there is nothing
like that below the `reverse_proxy` line.
## 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 `8085` or `5432`.
If you do not: delete anything for `8085` or `5432` with `sudo ufw delete allow 8085`.
8085 is bound to 127.0.0.1 by the compose file and 5432 is never published at all, so the
database has no host port that a firewall rule could even apply to. 80/tcp redirects to
HTTPS and answers the ACME challenge, 443/tcp is the only way in, and 443/udp is HTTP/3.
## 7. Start and verify
The first boot is slow. Prisma applies the whole database schema before the app answers
anything, so expect a 502 for the first few minutes.
```bash
cd /srv/linkwarden
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sSL -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sSL https://<DOMAIN>/ | grep -ci 'linkwarden'
```
You should see: the loop printing `502` a few times and then `200`, and the second command
printing a number greater than `0`.
If you do not: if the loop runs the whole ten minutes, look at both containers rather than
guessing. `docker compose ps` should show `linkwarden-db` as `healthy`; if it is not, that
is step 2, and `docker compose logs --tail 20 postgres` will say so in one line about
ownership. If the database is healthy and the answer is still 502, run
`docker compose logs -f linkwarden` and watch: if migration lines are moving, wait. A
container listed in `docker ps` is not proof of anything.
Now open https://<DOMAIN> in a browser. The first screen is a login form with fields for a
username and a password, and a link to create an account. Open https://<DOMAIN>/register
and create your account now, because registration is open until you close it in the next
command and this is the one window in the install a stranger could walk into.
Once you can sign in, close registration. A restart is not enough here: upstream documents
that the containers have to be recreated for a changed `.env` to take effect.
```bash
cd /srv/linkwarden
sed -i 's/^NEXT_PUBLIC_DISABLE_REGISTRATION=false$/NEXT_PUBLIC_DISABLE_REGISTRATION=true/' /srv/linkwarden/.env
grep NEXT_PUBLIC_DISABLE_REGISTRATION /srv/linkwarden/.env
docker compose down
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sSL -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
```
You should see: `NEXT_PUBLIC_DISABLE_REGISTRATION=true` from the grep, then the loop
reaching `200` again. Then sign out, try to create a second account at
https://<DOMAIN>/register, and confirm it is refused.
If you do not: a second account that still succeeds means `docker compose down` did not
actually run, so check `grep` printed `true` and run
`docker compose down && docker compose up -d` again. Do not leave this step half done.
Registration on a bookmark server that anyone can find is an invitation.
## 8. First backup and restore
Two artifacts, because there are two kinds of state. The database holds the links, the
tags and your account. The data directory holds the archived copies, which no database
dump contains.
```bash
cd /srv/linkwarden
docker compose exec -T postgres pg_dump -U postgres -d postgres | gzip > /srv/linkwarden/backups/linkwarden-db-$(date +%F).sql.gz
sudo tar -C /srv/linkwarden -czf /srv/linkwarden/backups/linkwarden-files-$(date +%F).tar.gz data .env
ls -lh /srv/linkwarden/backups/
```
You should see: two files, the `.sql.gz` a few kilobytes on a fresh install and the
`.tar.gz` similar. Nothing goes offline: `pg_dump` snapshots a running database
consistently, which is exactly why the database is dumped rather than copied off disk.
If you do not: a `.sql.gz` of 20 bytes is an empty dump, which means `pg_dump` failed and
the shell still created the file. Run the dump line without the `| gzip` part to read the
error.
A backup on the same disk as the data is not a backup. Run this one on your own machine,
not on the server:
```bash
mkdir -p ~/backups/linkwarden
scp vps:/srv/linkwarden/backups/* ~/backups/linkwarden/
```
You should see: two files copied, and both listed by `ls -lh ~/backups/linkwarden/`.
If you do not: `Permission denied (publickey)` means you ran it on the server by mistake.
The `vps:` prefix only means something on your own machine.
Now prove the restore, because a backup you have never restored is a guess. Do it today,
while the only thing at risk is a test account:
```bash
cd /srv/linkwarden
docker compose down
sudo rm -rf /srv/linkwarden/data /srv/linkwarden/postgres
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/linkwarden/data
sudo install -d -m 700 /srv/linkwarden/postgres
docker compose up -d postgres
sleep 30
gunzip -c /srv/linkwarden/backups/linkwarden-db-$(date +%F).sql.gz | docker compose exec -T postgres psql -U postgres -d postgres
sudo tar -C /srv/linkwarden -xzf /srv/linkwarden/backups/linkwarden-files-$(date +%F).tar.gz
docker compose up -d
```
You should see: `CREATE TABLE` and `COPY` lines from psql, then a login page where your
account still works.
If you do not: `role "postgres" does not exist` means the database container had not
finished initialising, so wait and run the `gunzip` line again. The dump alone gives you
your links with dead previews; the archive alone gives you files nothing points at. Both
or neither.
## 9. Updating later
New versions are listed at https://github.com/linkwarden/linkwarden/releases. Take both
backup artifacts first, then edit the `image:` line in /srv/linkwarden/compose.yml to the
new tag and its digest.
```bash
cd /srv/linkwarden
docker compose pull
docker compose up -d
docker compose logs --tail 30 linkwarden
```
You should see: migration lines, then the app starting, and no repeating restart.
If you do not: put the old tag and digest back and run the same three commands. A database
that has been migrated by a newer version will not load into an older image, which is why
the backup goes first rather than second.
## 10. What will probably go wrong
The first four minutes. `docker compose up -d` returned straight away, both containers
showed as running, and the hostname answered 502 for long enough that I reached for the
rollback. Nothing was broken: Prisma was applying the schema, and the app answers nothing
until that finishes. The tell is `docker compose logs -f linkwarden`, where the migration
lines visibly progress. If the log is moving, wait. If it has been silent for two minutes
and you are still getting a 502, look at the database container instead.
## 11. Out of scope
- Do not add MeiliSearch. It is a third container and a third secret, and search over the
text of archived pages is not what this install gives you.
- Do not configure SMTP. Email verification and password reset stay off, which is
survivable when you are the only account.
- Do not wire up an SSO or OAuth provider. Credentials login is on, and it is the only
path here.
- Do not set an AI tagging key. Automatic tagging sends page text to a third party, and
that is a decision to make on purpose later.agent-readable mirror: /self-host/raindrop.md
The files, if you'd rather do it yourself
The same install with no agent involved: three files, in the order you'd use them. The prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs.
compose.ymlthe services, pinned46 lines
# Linkwarden · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# setup and env vars . https://docs.linkwarden.app/self-hosting/setup
# variable reference . https://docs.linkwarden.app/self-hosting/environment-variables
# reverse proxy ...... https://docs.linkwarden.app/self-hosting/reverse-proxy
#
# Two services: the app, and the PostgreSQL it needs. MeiliSearch is deliberately
# absent, because Linkwarden only starts its search client when MEILI_MASTER_KEY
# is set, so leaving it out costs a container and a secret. Tags and digests were
# read from the registries on 2026-08-05; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
container_name: linkwarden-db
restart: unless-stopped
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- /srv/linkwarden/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
retries: 12
# No `ports:` at all: 5432 is reachable only from the other container.
linkwarden:
image: ghcr.io/linkwarden/linkwarden:v2.16.0@sha256:d805877fb707d160b809027c302f84cfba11a248d7fdc12de90b4791f98e6b55
container_name: linkwarden
restart: unless-stopped
env_file: /srv/linkwarden/.env
environment:
# Built here, not in .env: compose expands ${...} in this file.
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres
volumes:
# Archives, screenshots, PDFs, uploads. STORAGE_FOLDER defaults to `data`
# and the image's working directory is /data, hence /data/data.
- /srv/linkwarden/data:/data/data
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8085.
- "127.0.0.1:8085:3000"
depends_on:
postgres:
condition: service_healthyCaddyfilethe hostname and TLS26 lines
# Linkwarden · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.linkwarden.app/self-hosting/reverse-proxy 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 documents nginx
# only, and every forwarding header their example sets by hand is one Caddy sets
# on its own, which is why there is no header_up line below.
<DOMAIN> {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
-Server
}
# 8085 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:8085
}install.shthe same install, no agent145 lines
#!/usr/bin/env bash
# Linkwarden · 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://docs.linkwarden.app/self-hosting/setup
# https://docs.linkwarden.app/self-hosting/environment-variables
# https://docs.linkwarden.app/self-hosting/reverse-proxy
#
# Two secrets are generated here, on this machine: the PostgreSQL password and
# the NextAuth signing secret. Both go into /srv/linkwarden/.env with mode 600 and
# neither is ever printed.
#
# Registration is left open until you have created your own account, and this
# script stops and tells you to do that. It does not close it for you, because
# closing it before the account exists locks you out of your own install.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/linkwarden}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
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 2048 ] || die "only ${avail_mb} MB of RAM available; page preservation runs a headless Chromium and wants 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 20 ] || die "only ${avail_gb} GB free on /srv; archives grow, and this install wants 20 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 ----------------------------------------------------
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups" "$APP_DIR/data"
sudo install -d -m 700 "$APP_DIR/postgres"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
# --- 3. Generate the two secrets, on the server ------------------------------
#
# Hex rather than base64 for both: the database password ends up inside a
# connection URL, where the base64 alphabet needs escaping. Read them later with
# sudo grep -E 'POSTGRES_PASSWORD|NEXTAUTH_SECRET' /srv/linkwarden/.env
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
NEXTAUTH_URL=https://${DOMAIN_HOST}/api/v1/auth
NEXT_PUBLIC_DISABLE_REGISTRATION=false
NEXTAUTH_SECRET=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -hex 32)
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-linkwarden"
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 neither 8085 nor 5432 is 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; 8085 and 5432 stay 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 first boot is slow. Prisma applies the whole schema before Next.js starts
# answering, so a 502 for the first few minutes is the normal case, not a fault.
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/ (Prisma migrations, then a certificate)"
for _ in $(seq 1 60); do
code="$(curl -sSL -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/" || true)"
[ "$code" = "200" ] && break
sleep 10
done
[ "${code:-}" = "200" ] || die "https://${DOMAIN_HOST}/ answered ${code:-nothing} after ten minutes. Check: docker compose logs --tail 50 linkwarden"
curl -sSL "https://${DOMAIN_HOST}/" | grep -qi 'linkwarden' \
|| die "the page answered 200 but does not mention Linkwarden. Check: docker compose logs linkwarden"
# --- 7. The first backup, before day one ends --------------------------------
#
# Two artifacts, because there are two kinds of state: the database holds the
# links and the account, the data directory holds the archived copies.
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T postgres pg_dump -U postgres -d postgres | gzip > "$APP_DIR/backups/linkwarden-db-${STAMP}.sql.gz"
sudo tar -C "$APP_DIR" -czf "$APP_DIR/backups/linkwarden-files-${STAMP}.tar.gz" data .env
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/linkwarden-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
cat <<-DONE
Linkwarden is running at https://${DOMAIN_HOST}/
1. Open https://${DOMAIN_HOST}/register NOW and create your account.
Registration is open until you close it in step 2, so this window is
the one part of the install a stranger could walk into.
2. Then close it:
sed -i 's/^NEXT_PUBLIC_DISABLE_REGISTRATION=false$/NEXT_PUBLIC_DISABLE_REGISTRATION=true/' $APP_DIR/.env
cd $APP_DIR && docker compose down && docker compose up -d
A restart is not enough: the containers have to be recreated for a
changed .env to take effect. Then try to register a second account and
confirm it is refused.
3. Both secrets are in $APP_DIR/.env, mode 600. Read them with
sudo grep -E 'POSTGRES_PASSWORD|NEXTAUTH_SECRET' $APP_DIR/.env
and put them in your password manager. Neither was printed here.
4. First backup written to $APP_DIR/backups: a database dump and a file
archive. They are on the same disk as the data, which is not a backup.
Copy them somewhere else tonight.
DONEWhat 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 Raindrop.io.
- You own a PostgreSQL. Two containers, and the database is the part that has to be backed up with pg_dump rather than copied off disk, because a file copy of a running database is not a restore you can rely on.
- Preserving a page runs a headless Chromium. On a 2 GB box a large import is slow and can be stopped by the OOM killer partway through, which looks like a random failure and is actually a sizing decision made at checkout.
- No search inside the archives. Raindrop Pro's full-text search over saved page contents needs MeiliSearch, a third container and a third secret, and this install leaves it out on purpose.
- Archives grow, and faster than people expect. Every saved link can leave a screenshot, a PDF and a single-file HTML copy behind, so the disk floor here is about the third month rather than the first day.
- Registration is open for exactly one step of the install, and closing it needs the containers recreated rather than restarted. Get that wrong and anyone who finds the hostname can sign up.
Where this came from
“Linkwarden has pretty minimal hardware requirements - it was tested on a VPS with 4gb of memory and it ran pretty smoothly”
- Upstream's setup docs require NEXTAUTH_URL with an /api/v1/auth suffix, NEXTAUTH_SECRET and POSTGRES_PASSWORD, and say the secret values must be changed from the samples. source
- MeiliSearch is optional: Linkwarden only initialises its search client when MEILI_MASTER_KEY is set, which is why this install runs two containers rather than three. source
- Self-service registration is closed by setting NEXT_PUBLIC_DISABLE_REGISTRATION, and a changed environment file needs the containers recreated rather than restarted. source
- Upstream's reverse proxy guide assumes Linkwarden on http://127.0.0.1:3000 and documents nginx, setting by hand the forwarding headers Caddy sets on its own. source
- Caddy obtains and renews TLS certificates automatically for any public hostname named in the Caddyfile. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Raindrop.io?
Not Raindrop.io 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 Linkwarden. Bookmarks that keep their own copy of the page, so a dead link is still readable years later. The install is one evening: 2 containers 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 90 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 Raindrop.io?
Linkwarden. Bookmarks that keep their own copy of the page, so a dead link is still readable years later. The only self-hosted option that matches the reason people pay Raindrop: it keeps its own copy of every page it saves, as a screenshot, a PDF and a single-file HTML archive, so a dead link is still readable years later. It costs a PostgreSQL you operate and a headless browser that wants real memory, and searching inside those archives needs MeiliSearch, which our install leaves out. Linkwarden is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Raindrop.io?
2048 MB of RAM and 20 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. Linkwarden itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Raindrop.io Pro, $3/mo — $36 a year.
How hard is it really?
ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the Linkwarden install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Content last checked 2026-08-05. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.