# Can I self-host Raindrop.io?

**YES** — it's called Linkwarden. ONE EVENING setup · ~1.5 hours to running · 2 GB RAM minimum · $3/mo you stop paying ($36/yr on the Pro plan).

Linkwarden authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/raindrop/

## Install prompt (Claude Code)

````text
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.
````

## Chat fallback

````text
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.
````

## Local install prompt (your own computer, no server)

````text
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 Linkwarden 2.16.0 on this computer, reachable at http://localhost:8085, with
everything it owns under ~/selfhost/linkwarden.

## 1. Preflight

Detect the OS, then measure the machine:

```bash
uname -s
uname -m
case "$(uname -s)" in
  Darwin) sysctl -n hw.memsize | awk '{printf "%d MB installed\n", $1/1048576}' ;;
  Linux) free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory" ;;
esac
df -h ~
```

`uname -s` prints `Darwin` on macOS, `Linux` on Linux, `MINGW` or `MSYS` in Git Bash on
Windows. Record which: steps 2, 3, 4 and 8 branch on it. The Windows line prints bytes, so
divide by 1048576; it and the macOS line report RAM installed, the Linux line what is free
now.

Linkwarden needs 2048 MB of RAM available and 20 GB free on the home disk. Preserving a
page runs a headless Chromium, and every saved link can leave a screenshot, a PDF and an
HTML copy, so the disk floor is month three. `uname -m` prints `x86_64`, `arm64` or
`aarch64`; both images publish amd64 and arm64, so nothing branches on it.

Stop rule: if the RAM figure is under 2048 MB or free space on `~` is under 20 GB, print
both and stop. The failure here is the OOM killer taking the archiver out mid-import: it
looks random, it was a sizing decision. On macOS and Windows the binding ceiling is Docker
Desktop's VM, not the machine, and step 7 measures it.

## 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:

```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
```

  Tell the user in one sentence that `docker` group membership is root-equivalent here.
  Then STOP: have them log out, log back in, and run this prompt again from step 2. The
  group is not active in this shell until they do, so the assert below cannot pass. Do not
  continue until they confirm.

- 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/linkwarden/data ~/selfhost/linkwarden/backups
ls -la ~/selfhost/linkwarden
```

Assert: `ls -la` shows `data` and `backups`. Everything this install owns lives here, apart
from the volume step 5 declares.

Ownership: on Linux the container writes into `./data` as the uid the image runs as, so
removing it later takes `sudo`; nothing needs chowning up front. On macOS and Windows,
Docker Desktop's file sharing owns that.

## 4. Secrets

Two secrets: the PostgreSQL password and the NextAuth signing secret. Generate both here,
print neither, keep both out of your summary and any log line. Hex, not base64: the
password ends up inside a connection URL.

```bash
umask 077
cat > ~/selfhost/linkwarden/.env <<EOF
NEXTAUTH_URL=http://localhost:8085/api/v1/auth
NEXT_PUBLIC_DISABLE_REGISTRATION=false
NEXTAUTH_SECRET=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/linkwarden/.env
umask 022
ls -l ~/selfhost/linkwarden/.env
```

Assert: the file exists, and on macOS and Linux its mode reads `-rw-------`. In Git Bash
`ls -l` prints `-rw-r--r--` whatever `chmod` did, because NTFS keeps its own permissions,
and that is expected, not a failure. Say one line out loud on Windows: the mode bits are
advisory, and the real boundary is the Windows account another user does not have.

`NEXTAUTH_URL` is the base address Linkwarden builds its login redirects from, so it names
port 8085 and carries the `/api/v1/auth` suffix upstream requires; get it wrong and sign-in
fails like a bad password. Registration is open until step 7 closes it. Tell the user
`grep -E 'POSTGRES_PASSWORD|NEXTAUTH_SECRET' ~/selfhost/linkwarden/.env` prints both values
and that they belong in a password manager now.

## 5. compose.yml

```bash
cat > ~/selfhost/linkwarden/compose.yml <<'EOF'
# Linkwarden · the deterministic fallback for the local path. 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
#
# Lives in ~/selfhost/linkwarden/; paths are relative to that folder, which lets
# one file work on macOS, Linux and Windows. MeiliSearch is absent on purpose:
# the search client starts only when MEILI_MASTER_KEY is set. Digests match
# compose.yml, read 2026-08-05. The database is a named volume, not a bind mount:
# PostgreSQL chowns its data directory to a uid Windows bind mounts cannot grant.
#
# 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:
      - linkwarden-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      retries: 12
    # No `ports:`: 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: ./.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`
      # under the working directory /data, hence /data/data.
      - ./data:/data/data
    ports:
      # Loopback only. Nothing outside this computer can reach 8085.
      - "127.0.0.1:8085:3000"
    depends_on:
      postgres:
        condition: service_healthy

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

Assert: that prints `compose OK`. Two services, one published port, on loopback.

## 6. Nothing is public

Nothing here is reachable from outside this computer. That is the shape of this path, not a
gap in it. State all four:

- The only published port is `127.0.0.1:8085`. A program on this machine can connect to it;
  a laptop on the same wifi cannot, nothing listens on the network address.
- There is no domain and no certificate, because there is nothing to certify. Browsers
  treat `http://localhost` as a secure context, so in-page crypto works over plain HTTP.
- The database publishes no port. The app container reaches it, nothing else does.
- Any browser here reaches http://localhost:8085, the Linkwarden extension included. A
  phone cannot, on any network, and nothing changes that.

## 7. Start and verify

On macOS and Windows the containers get the VM's memory, not the machine's:

```bash
docker info --format '{{.MemTotal}}'
```

Divide by 1048576. Under 2048, STOP: have the user raise Docker Desktop's memory limit in
Settings, Resources, and confirm Docker restarted.

The first boot is slow: Prisma applies the whole schema before Next.js answers, so refused
connections for the first few minutes are normal.

```bash
cd ~/selfhost/linkwarden
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sSL -o /dev/null -w '%{http_code}' http://localhost:8085/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sSL http://localhost:8085/ | grep -ci 'linkwarden'
```

Assert: the loop ends printing `200`, and the second command prints a number above `0`,
because `Linkwarden` appears in the served document. Print what you received. If the loop
runs out, stop, run `docker compose logs --tail 50 linkwarden` and
`docker compose logs --tail 20 postgres`, and name the likely cause: a database that never
reports healthy points at step 5, an app log still in migrations wants time. The first
screen is a login form with a username, a password, and a link to create an account.

STOP: tell the user to open http://localhost:8085/register in a browser, create their
account, and wait. Do not continue until they confirm they can sign in.

Then close registration. A restart is not enough: upstream documents that a changed `.env`
needs the containers recreated. macOS `sed` refuses a bare `-i`, hence `-i.bak`, `rm` and
`chmod`.

```bash
cd ~/selfhost/linkwarden
sed -i.bak 's/^NEXT_PUBLIC_DISABLE_REGISTRATION=false$/NEXT_PUBLIC_DISABLE_REGISTRATION=true/' .env
rm -f .env.bak
chmod 600 .env
grep NEXT_PUBLIC_DISABLE_REGISTRATION .env
docker compose down
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sSL -o /dev/null -w '%{http_code}' http://localhost:8085/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
```

STOP: tell the user to sign out, try to create a second account at
http://localhost:8085/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 refusal, all three. A running container is not success.

## 8. First backup and restore

Two artifacts: the database holds links, tags and the account, `data` the archived copies
no dump contains.

```bash
cd ~/selfhost/linkwarden
docker compose exec -T postgres pg_dump -U postgres -d postgres | gzip > backups/linkwarden-db-$(date +%F).sql.gz
tar -czf backups/linkwarden-files-$(date +%F).tar.gz data .env
ls -lh backups/
```

Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped:
`pg_dump` snapshots a running database consistently, so it is dumped rather than copied off
disk. On Linux, if `tar` reports a permission error inside `data`, run it again with
`sudo`: those files belong to the container's uid.

A backup on the same disk is not a backup, and here the disk and the computer fail
together: one dead SSD takes both. Ask the user once for a folder that leaves this
computer: one a sync service watches, iCloud Drive, OneDrive, Dropbox, or a mounted USB
stick, under /Volumes on macOS, usually /media on Linux, and a drive letter in Git Bash,
where `D:\backups` is typed `/d/backups`. Say plainly that the archive carries `.env`, so
whatever holds it holds the database password and the signing secret: a USB stick or an
end-to-end encrypted folder unless they would trust that service with a password. Copy both
of today's files there with `cp` using the path they gave, then `ls -lh` it. Assert: both
appear there, with sizes.

To restore: `docker compose down -v`, the one place `-v` belongs: it drops the database
volume; `rm -rf ~/selfhost/linkwarden/data`, with `sudo` on Linux; untar the file archive
into ~/selfhost/linkwarden first, since it carries the `.env` the database is built from;
`docker compose up -d postgres`; wait for `docker compose ps` to say healthy; pipe
`gunzip -c` on the `.sql.gz` into
`docker compose exec -T postgres psql -U postgres -d postgres`; `docker compose up -d`. The
dump alone gives dead previews, the archive alone files nothing points at.

## 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 compose.yml to the new tag and digest:

```bash
cd ~/selfhost/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 written by a newer image will not load into an older one; back up first.

## 10. What will probably go wrong

The morning after. I rebooted, opened the browser out of habit, and http://localhost:8085
refused the connection outright, which reads worse than a slow page. Nothing was wrong:
Docker Desktop does not start with the machine unless told to, and
`restart: unless-stopped` only brings the containers back when the daemon returns. Opening
it restored everything, and its start-at-login setting spares the next one.

## 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 add MeiliSearch. A third container and a third secret, for search inside archived
  pages, is not what this installs.
- Do not configure SMTP. Email verification and password reset stay off, survivable here.
- Do not set an AI tagging key. Automatic tagging sends saved page text to a third party,
  which is what this install exists to stop.
````

## docker-compose.yml

```yaml
# 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
```

## compose.local.yml

```yaml
# Linkwarden · the deterministic fallback for the local path. 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
#
# Lives in ~/selfhost/linkwarden/; paths are relative to that folder, which lets
# one file work on macOS, Linux and Windows. MeiliSearch is absent on purpose:
# the search client starts only when MEILI_MASTER_KEY is set. Digests match
# compose.yml, read 2026-08-05. The database is a named volume, not a bind mount:
# PostgreSQL chowns its data directory to a uid Windows bind mounts cannot grant.
#
# 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:
      - linkwarden-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      retries: 12
    # No `ports:`: 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: ./.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`
      # under the working directory /data, hence /data/data.
      - ./data:/data/data
    ports:
      # Loopback only. Nothing outside this computer can reach 8085.
      - "127.0.0.1:8085:3000"
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  linkwarden-pgdata:
```

## Caddyfile

```text
# 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.sh

```bash
#!/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.

DONE
```

The page this mirrors: https://caniselfhostit.com/self-host/raindrop/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
