VPS Tutorials
Self-Host Linkwarden Bookmark Manager on a VPS (2026): Docker Compose, PostgreSQL, MeiliSearch, Caddy HTTPS and Backup Security Configuration
This tutorial explains how to deploy Linkwarden on a VPS with Docker Compose to build your own bookmark manager with web archiving, screenshots, full-text search and team collaboration. It covers PostgreSQL, MeiliSearch, Caddy HTTPS, NEXTAUTH_SECRET, NEXTAUTH_URL, disabling registration, backups and upgrades, user content domain isolation and common troubleshooting.

Self-Host Linkwarden Bookmark Manager on a VPS (2026): Docker Compose, PostgreSQL, MeiliSearch, Caddy HTTPS and Backup Security Configuration
The problem with browser bookmarks isn't that you can't save things, it's that once saved, they're hard to find again.
You've probably saved plenty of articles, tools, papers, product pages, GitHub repositories and tutorial links. A few months later, the page may be a 404, the title has changed, the content is behind a login wall, or you simply can't remember why you saved it in the first place.
Linkwarden solves this problem: it's not just a bookmark list, but a self-hostable web collection and archiving system. It can save links, generate screenshots, archive web page content, organize by collection, support full-text search, and it also works well for small teams sharing a knowledge base.
This article will deploy Linkwarden on a VPS using Docker Compose, with Caddy providing HTTPS. We'll focus on explaining PostgreSQL, MeiliSearch, key environment variables, disabling public registration, backups and upgrades, and security boundaries, rather than copying all several hundred lines of configuration from the official .env.sample.
Linkwarden suits these scenarios:
- You want to self-host a Raindrop, Pocket or Pinboard replacement.
- You need to preserve web links, screenshots and archived content long term.
- You want your bookmarks to be full-text searchable, not just searchable by title.
- You want to maintain a knowledge base, competitor library, tutorial library or asset library for a team.
- You don't want all your reading and bookmark data on a third-party platform.
It's less suited to these scenarios:
- You only want to temporarily save a few links, and browser bookmarks are enough.
- You mainly want to read RSS, and FreshRSS is a better fit.
- You only need read-later, and Wallabag may be lighter.
- You don't want to maintain a database, backups and a reverse proxy.
Linkwarden's keywords are "collect + archive + search". If you only need an RSS reader, it's not a FreshRSS replacement; if you need to preserve web page evidence, reference links and a team knowledge entry point, it will suit you better.
Linkwarden is somewhat heavier than FreshRSS, because by default it uses PostgreSQL to store data, MeiliSearch for search, and Playwright / Chromium to generate web page screenshots and archives.
| Use case | Recommended configuration |
|---|---|
| Light personal use | 1-2 vCPU / 2GB RAM / 20GB SSD |
| Long-term personal archiving, many screenshots | 2 vCPU / 4GB RAM / 40GB SSD |
| Small team shared knowledge base | 2-4 vCPU / 4GB+ RAM / 80GB+ SSD |
The official installation documentation mentions that a 4GB RAM VPS runs fairly smoothly. 1GB of memory may also run it, but it gets tight when taking screenshots and archiving web pages. Linkwarden's resource consumption mainly comes from three parts: the database, the search index, and web page archiving and screenshots.
If your VPS only has 1GB of memory left and is already running databases, panels, monitoring, AI gateways and other services, it's not advisable to cram Linkwarden in as well. It can run, but the experience and stability won't be good.
The official Docker Compose mainly contains three types of services:
| Service | Role |
|---|---|
| Linkwarden | Web application, API, archiving tasks, screenshot processing |
| PostgreSQL | Stores core data such as users, links, collections and configuration |
| MeiliSearch | Provides fast full-text search |
In addition, this article adds a Caddy container to automatically obtain HTTPS certificates and reverse proxy to Linkwarden.
The official Linkwarden image is:
ghcr.io/linkwarden/linkwarden:latest
The default application port is 3000.
This article assumes:
- The VPS system is Ubuntu 22.04 / 24.04 or Debian 12.
- Docker and Docker Compose V2 are installed.
- You have a domain ready, for example
links.example.com. - The domain's A record points to the VPS public IP.
- Ports 80 and 443 can be opened for Caddy.
Create the directories:
sudo mkdir -p /opt/linkwarden
sudo chown -R "$USER":"$USER" /opt/linkwarden
cd /opt/linkwarden
mkdir -p data pgdata meili_data caddy-data caddy-config backups
Directory purposes:
| Directory | Purpose |
|---|---|
data | Linkwarden archive files, screenshots, PDFs, uploaded content |
pgdata | PostgreSQL database files |
meili_data | MeiliSearch search index |
caddy-data | Caddy certificates and runtime data |
caddy-config | Caddy configuration data |
backups | Local backup directory |
All these directories must be included in backups. Especially pgdata and data: one is structured data and the other is archive files, and a restore is incomplete without either.
Create .env:
nano .env
Write the following content, changing the domain and passwords to your own:
LINKWARDEN_DOMAIN=links.example.com
POSTGRES_PASSWORD=replace-with-a-long-random-password
NEXTAUTH_SECRET=replace-with-a-long-random-secret
MEILI_MASTER_KEY=replace-with-a-long-random-meili-key
NEXTAUTH_URL=https://links.example.com/api/v1/auth
BASE_URL=https://links.example.com
NEXT_PUBLIC_DISABLE_REGISTRATION=true
NEXT_PUBLIC_CREDENTIALS_ENABLED=true
You can generate random keys with:
openssl rand -base64 32
There are several key points here:
NEXTAUTH_SECRETmust be a strong random value, used to sign sessions and JWTs.POSTGRES_PASSWORDis the PostgreSQL password.MEILI_MASTER_KEYis the MeiliSearch master key; don't leave it empty.- All three values should be long, unique, high-entropy random strings; don't reuse old passwords, and don't use the placeholders in the example.
NEXTAUTH_URLmust have the/api/v1/authsuffix; this is a very easy mistake to make since Linkwarden v2.BASE_URLuses the publicly accessible address; emails and some links use it.NEXT_PUBLIC_DISABLE_REGISTRATION=trueis recommended to stay enabled after creating the first account, to avoid open public registration.
If you don't have an account yet on first deployment, you can first set NEXT_PUBLIC_DISABLE_REGISTRATION=false, create the admin account, then change it back to true and run:
docker compose down
docker compose up -d
Note: after changing .env, don't just run docker compose restart. The official documentation also reminds that environment variable changes require down followed by up -d to be reloaded.
Create docker-compose.yml:
nano docker-compose.yml
Write:
services:
postgres:
image: postgres:16-alpine
container_name: linkwarden-postgres
restart: unless-stopped
env_file:
- .env
environment:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- ./pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
meilisearch:
image: getmeili/meilisearch:v1.12.8
container_name: linkwarden-meilisearch
restart: unless-stopped
env_file:
- .env
environment:
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY}
MEILI_NO_ANALYTICS: "true"
volumes:
- ./meili_data:/meili_data
linkwarden:
image: ghcr.io/linkwarden/linkwarden:latest
container_name: linkwarden
restart: unless-stopped
env_file:
- .env
environment:
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres
MEILI_HOST: http://meilisearch:7700
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY}
ports:
- "127.0.0.1:3000:3000"
volumes:
- ./data:/data/data
depends_on:
postgres:
condition: service_healthy
meilisearch:
condition: service_started
caddy:
image: caddy:2-alpine
container_name: linkwarden-caddy
restart: unless-stopped
env_file:
- .env
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy-data:/data
- ./caddy-config:/config
depends_on:
- linkwarden
Here Linkwarden's 3000 port is bound to 127.0.0.1, so it can't be accessed directly from the internet. What's actually exposed externally is Caddy's HTTPS.
If you're sure only Caddy in the same Compose stack needs to access Linkwarden, you can also delete the ports section entirely, leaving Linkwarden exposed only on the Docker internal network. This article keeps 127.0.0.1:3000:3000, mainly for local debugging convenience while avoiding direct public access.
PostgreSQL and MeiliSearch don't expose ports to the internet; they're only accessed by Linkwarden within the Docker network. Don't expose 5432 or 7700 directly to the internet.
Create Caddyfile:
nano Caddyfile
Write:
{$LINKWARDEN_DOMAIN} {
encode zstd gzip
reverse_proxy linkwarden:3000
}
Open the firewall:
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw reload
If your cloud provider has security groups, allow 80 and 443 there too. Caddy needs these two ports to complete HTTP-01 certificate issuance and HTTPS access.
If you use the Cloudflare proxy, the SSL/TLS mode should be Full (strict), not Flexible. Flexible easily causes problems with origin protocol, redirects and login callbacks.
Run:
cd /opt/linkwarden
docker compose up -d
Check the containers:
docker compose ps
Check the logs:
docker compose logs -f --tail=100 linkwarden
Open your browser and visit:
https://links.example.com
Create an account after entering for the first time. Once created, confirm that .env contains:
NEXT_PUBLIC_DISABLE_REGISTRATION=true
Then recreate the containers so the environment variables take effect:
docker compose down
docker compose up -d
This way public users can't register accounts freely.
MeiliSearch is a search engine, and if you don't set MEILI_MASTER_KEY, access control will be weak. Although this article doesn't expose 7700 to the internet, internal services should also have a key.
The correct approach:
MEILI_MASTER_KEY=replace-with-a-long-random-meili-key
And configure both in the Linkwarden service:
MEILI_HOST: http://meilisearch:7700
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY}
Don't remove MeiliSearch just to save effort. Linkwarden's advanced search depends on it, and the difference becomes obvious as the number of bookmarks grows.
Linkwarden saves web page snapshots, HTML archives, screenshots and PDFs. Archived web pages are essentially "untrusted content" that may contain strange scripts, malicious HTML or deceptive pages.
The official project provides a User Content Domain feature that lets you put archived content on a separate domain, for example:
links.example.com
usercontent.example.com
This way the main application and archived content aren't on the same origin, making the security boundary clearer. The key here is "separate hostname / separate origin"; don't keep reusing the main application domain.
For light personal use you can skip this for now, but if you'll save lots of unfamiliar web pages and team members will open archived content from each other, it's advisable to plan a user content domain early.
The general idea is:
- The main domain
links.example.comaccesses the Linkwarden application. - The content domain
usercontent.example.comis only used for accessing archived content. - Both domains can point to the same VPS.
- Caddy or Nginx distinguishes forwarding and access rules by host.
- Set
NEXT_PUBLIC_USER_CONTENT_DOMAINin.env.
This is an advanced security configuration. This article first completes the main application deployment, but you should at least know: web page archives are not fully trusted static resources.
Linkwarden backups can't just back up a single directory. At minimum they should include:
- The PostgreSQL database.
- The
dataarchive file directory. - The MeiliSearch index directory.
.env,docker-compose.yml,Caddyfile.
Create a backup script:
nano backup-linkwarden.sh
Write:
#!/usr/bin/env bash
set -euo pipefail
APP_DIR="/opt/linkwarden"
BACKUP_DIR="$APP_DIR/backups"
DATE="$(date +%F-%H%M)"
mkdir -p "$BACKUP_DIR/$DATE"
cd "$APP_DIR"
# 先停止会写入数据库和归档文件的服务,避免 SQL 快照和 data 目录时间点不一致。
docker compose stop linkwarden meilisearch
# 备份 PostgreSQL 逻辑数据。PostgreSQL 容器保持运行即可执行 pg_dump。
docker compose exec -T postgres pg_dump -U postgres postgres > "$BACKUP_DIR/$DATE/postgres.sql"
# 备份归档文件、搜索索引和配置文件。
tar czf "$BACKUP_DIR/$DATE/linkwarden-files.tar.gz" data meili_data docker-compose.yml Caddyfile .env
# 重新启动服务。
docker compose start meilisearch linkwarden
# 保留最近 14 天备份。
find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -mtime +14 -exec rm -rf {} \;
echo "Backup saved to $BACKUP_DIR/$DATE"
This order matters: stop Linkwarden first, then do the pg_dump and file archiving. Otherwise the database may already record an archive file, but the file hasn't finished writing when you package the data directory, or the reverse happens and files don't match the database.
Add execute permissions and test:
chmod +x backup-linkwarden.sh
./backup-linkwarden.sh
ls -lh backups
Scheduled task:
crontab -e
Back up every day at 3 AM:
0 3 * * * /opt/linkwarden/backup-linkwarden.sh >> /opt/linkwarden/backups/backup.log 2>&1
Local backups are only the first layer. It's recommended to also use rclone to sync to object storage, a NAS or another VPS.
If you change archived content to S3 / MinIO / object storage, the backup focus changes: the database still needs backing up, and the object storage bucket also needs versioning or a snapshot strategy.
Stop the services before restoring:
cd /opt/linkwarden
docker compose down
First restore the file-level backup. This restores the data archive files, the meili_data search index, .env, docker-compose.yml and Caddyfile:
tar xzf /opt/linkwarden/backups/2026-07-09-0300/linkwarden-files.tar.gz -C /opt/linkwarden
Restoring only postgres.sql isn't enough, because screenshots, PDFs, HTML archives and uploaded files are all in the data directory. The file backup and database backup must be restored together for Linkwarden to be in a complete state after restoring.
Start PostgreSQL first:
docker compose up -d postgres
Wait for the database to be healthy, then restore the SQL:
docker compose exec -T postgres psql -U postgres postgres < /opt/linkwarden/backups/2026-07-09-0300/postgres.sql
Then start all services:
docker compose up -d
Check after restoring:
- Can you log in?
- Do collections and links exist?
- Can screenshots, PDFs and HTML archives be opened?
- Does search work?
- Is the Caddy certificate normal?
If the MeiliSearch index has problems, it can usually be rebuilt; but if the PostgreSQL and data directories are lost, core data and archive files will be affected.
Back up before upgrading:
cd /opt/linkwarden
./backup-linkwarden.sh
Upgrade:
docker compose pull
docker compose down
docker compose up -d
Why not just restart? Because when environment variables, images and service definitions change, restart won't fully reload the configuration.
Check after upgrading:
docker compose ps
docker compose logs --tail=100 linkwarden
If you're upgrading from a very old Linkwarden version, read the official upgrade notes first. Especially for the v1 to v2 migration, NEXTAUTH_URL must have /api/v1/auth, and additional migration steps may be needed.
First check:
NEXTAUTH_URL=https://links.example.com/api/v1/auth
BASE_URL=https://links.example.com
NEXTAUTH_URL shouldn't be just the root domain; it must have /api/v1/auth. This is one of the most common authentication problems.
After changing .env, run:
docker compose down
docker compose up -d
Confirm .env:
NEXT_PUBLIC_DISABLE_REGISTRATION=true
Then recreate the containers:
docker compose down
docker compose up -d
Just restarting may not load the new environment variables.
Check MeiliSearch:
docker compose logs --tail=100 meilisearch
Confirm that .env matches MEILI_MASTER_KEY in the Compose file, and that MEILI_HOST in Linkwarden is:
http://meilisearch:7700
Don't expose the MeiliSearch port to the internet.
Linkwarden uses a browser environment to process web pages. Failure reasons may be:
- The VPS has too little memory.
- The target web page has anti-scraping, a login wall or regional restrictions.
- DNS or IPv6 access anomalies.
- The target site's TLS certificate has problems.
- The container doesn't have enough time to complete the screenshot.
Check the logs first:
docker compose logs --tail=200 linkwarden
If many archiving tasks fail, first upgrade the VPS to 2GB or 4GB of memory, then investigate network and target site restrictions.
Check:
- Whether the domain resolves to the VPS.
- Whether 80/443 are allowed in the system firewall.
- Whether the cloud security group allows them.
- Whether Nginx/Apache is already occupying 80/443 on the server.
- Whether Cloudflare is using the wrong SSL/TLS mode.
Check the Caddy logs:
docker compose logs --tail=100 caddy
Before going live, at minimum do these:
- Don't expose PostgreSQL's port 5432.
- Don't expose MeiliSearch's port 7700.
- Bind Linkwarden only to
127.0.0.1:3000, or don't publish host ports at all and let only Caddy access it via the Docker network. - Use Caddy or Nginx to provide HTTPS.
- Use strong random values for
NEXTAUTH_SECRET,POSTGRES_PASSWORDandMEILI_MASTER_KEY. - Disable public registration after creating the first account.
- Don't upload
.envto GitHub. - Back up PostgreSQL and archive files regularly.
- If used by a team, consider configuring a user content domain to isolate archived HTML.
- Don't casually enable options that access private networks or skip TLS verification.
Linkwarden actively visits the web page links you save, so like RSSHub and FreshRSS, you must pay attention to the boundaries of server-side requests. By default, don't let it access internal network addresses, and don't treat it as an arbitrary web page scraping proxy.
| Tool | Better suited for |
|---|---|
| Linkwarden | Collecting links, archiving web pages, team knowledge base, full-text search |
| FreshRSS | RSS subscriptions, reading feeds, read-state sync |
| Wallabag | Read later, saving article body text |
| Nextcloud Bookmarks | Simple bookmark sync, suitable for Nextcloud users |
If you read feeds every day, choose FreshRSS. If you want to preserve "web page material you'll use later", choose Linkwarden. If you just want to read long articles later, Wallabag is lighter.
Check before formal use:
- The domain resolves to the VPS.
- 80/443 are allowed in the firewall and security group.
-
NEXTAUTH_URLis set tohttps://links.example.com/api/v1/auth. -
BASE_URLis set to the public HTTPS domain. -
NEXTAUTH_SECRETis a strong random value. -
POSTGRES_PASSWORDis a strong random value. -
MEILI_MASTER_KEYis a strong random value. - PostgreSQL is not exposed to the internet.
- MeiliSearch is not exposed to the internet.
- Linkwarden is bound only to local
127.0.0.1:3000, or no host port is published. - Registration is disabled after creating the account.
- The backup script has been tested.
- At least one restore drill has been done.
- User content domain isolation has been evaluated for team use.
Linkwarden is well suited to running long term on a VPS. It's more reliable than browser bookmarks, better suited to archiving than simple bookmark tools, and better suited to being a knowledge base than pure read-later tools.
When deploying, the most important thing isn't just getting the service running, but getting a few key points right: don't expose PostgreSQL to the internet, set a MeiliSearch master key, make NEXTAUTH_URL include /api/v1/auth, disable registration after creating the account, and back up the database and archive files regularly.
If you already have a VPS with 2GB or 4GB of memory, Linkwarden is a bookmark manager well worth self-hosting. It can turn "read later" links into a truly searchable, archivable, long-term knowledge base.