Use case · Bookmark archive

Linkwarden self hosted on a dedicated server: Compose, archive, upkeep

Self-hosted Linkwarden is three containers from the project's own docker-compose.yml: the Linkwarden app with its archiving worker, PostgreSQL 16 and Meilisearch, configured through one .env file. The docs size it at 4 GB of RAM and any 2-core CPU. What grows is the archive: each link is loaded in headless Chromium and stored as readable text, an HTML snapshot, a full-page JPEG and a PDF. Below: install with port 3000 held on loopback, register the administrator over SSH port forwarding, close sign-up, then put nginx and TLS in front.

See the machines

What Linkwarden needs from the machine

The setup guide gives three numbers, and the "Running a Lighter Setup" page explains where they go. Since release 2.15 the image is about 1.5 GB and a fresh instance idles near 350 MB; the rest of the 4 GB is headroom for the archiving browser, Postgres and Meilisearch while links are being preserved.

ResourceFigure in the docsWhat drives it
Memory4 GB as a comfortable start; about 350 MB idleHeadless Chromium during preservation, then Postgres and Meilisearch
CPUAny 2-core machineMonolith and PDF jobs are the costliest formats
DiskImage near 1.5 GB; data on topPreserved files under ./data, the database, the search index
ArchitectureImages for x86-64 and ARM64Docker pulls the one that matches your CPU

The docs give no per-link storage figure, so we size disk with our own rule: run it for a week with your real habits, read du -sh data pgdata meili_data in the install folder, and multiply by the growth you expect, with room for the backup copies kept beside them.

Which machines fit

Linkwarden is one service, so our own pick for a person or a small team is a Moonshot cartridge with NVMe storage from our dedicated server range; a single-socket rack server suits a team that also stores large archives or runs other containers beside it. Each line is a whole physical box, root included, on an unmetered port, which matters here: each saved link is fetched again by the server, and imports of thousands of links mean thousands of page loads.

  • Out-of-band console: racked single-, dual- and quad-socket servers carry one, as do m510 and m710x cartridges; the remaining cartridges offer power control alone. If you will be editing firewall rules over SSH, that difference matters.
  • IPv6 is not in any default build; it is added at checkout, free, and you need it for an AAAA record.
  • Delivery: a ready server, already racked, is handed over in about 30 minutes once payment is approved; a build to order takes 4 to 24 hours.

Skip the ARM X-Gene cartridge for this. It can be installed with Ubuntu 16.04 or 18.04 alone, while everything below assumes a current Ubuntu and Docker Engine 28+. Take an x86-64 line, which the published image supports.

Install with Docker Compose, private first

The official compose file publishes 3000:3000. A mapping with no address is published on every host address, 0.0.0.0 and [::], and Docker's NAT rules for published ports bypass the INPUT rules ufw writes for IPv4. On IPv6 the same happens only if IPv6 is switched on in the Docker daemon; otherwise a docker-proxy process listens on [::] and ufw does filter it. So our block binds the app to 127.0.0.1 instead; on Docker Engine 28+ that binding is reachable from the host only. Postgres and Meilisearch publish no ports in the compose file. Before any ufw rule exists, its first line switches IPV6=yes, so the SSH rule is written for both address families; it then downloads the two files exactly as the setup guide does.

# as root, on Ubuntu with Docker Engine 28+ and the Compose plugin already installed
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow OpenSSH
ufw --force enable
mkdir -p /opt/linkwarden && cd /opt/linkwarden
curl -O https://raw.githubusercontent.com/linkwarden/linkwarden/refs/heads/main/docker-compose.yml
curl -L https://raw.githubusercontent.com/linkwarden/linkwarden/refs/heads/main/.env.sample -o ".env"
chmod 600 .env
sed -i "s|^NEXTAUTH_SECRET=.*|NEXTAUTH_SECRET=\"$(openssl rand -hex 32)\"|" .env
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=\"$(openssl rand -hex 24)\"|" .env
sed -i "s|^MEILI_MASTER_KEY=.*|MEILI_MASTER_KEY=\"$(openssl rand -hex 24)\"|" .env
sed -i 's|- 3000:3000|- "127.0.0.1:3000:3000"|' docker-compose.yml
grep -q '"127.0.0.1:3000:3000"' docker-compose.yml && docker compose up -d \
  || echo "Port line not changed: edit docker-compose.yml by hand before starting"
  • NEXTAUTH_SECRET signs sessions, JWTs and preserved-content tokens; POSTGRES_PASSWORD builds the internal DATABASE_URL; MEILI_MASTER_KEY is the search engine key. The guide says all three must be changed and must differ, and to quote values with special characters.
  • Keep NEXTAUTH_URL on its sample value while you work through the tunnel; when you change it later it must still end in /api/v1/auth.
  • The generated secrets pass through sed's command line for a moment, where ps can show them to other users of the box; shell history keeps only the $(openssl ...) text. If you are the only login on the machine, that is acceptable; otherwise type them into nano .env.

First account, then close registration

Do this while the port is still loopback-only. The NEXT_PUBLIC_ADMIN variable defaults to user ID 1, the account treated as server administrator; on a fresh database, our reading is that the first sign-up gets that ID, so it must be yours.

  1. On your workstation, start a tunnel through your own login: ssh -N -L 3000:127.0.0.1:3000 you@server.example.net.
  2. Browse to http://localhost:3000, which matches the default NEXTAUTH_URL, and sign up.
  3. On the server, put NEXT_PUBLIC_DISABLE_REGISTRATION=true into .env (for example sed -i 's/^NEXT_PUBLIC_DISABLE_REGISTRATION=.*/NEXT_PUBLIC_DISABLE_REGISTRATION=true/' .env), and apply it: docker compose up -d recreates the app container.
  4. Reload the sign-up page and confirm it now refuses new accounts.

Per the project's source (postUser.ts and the admin user-administration page in the repository), an administrator may still add accounts while public sign-up is off, so colleagues are added by you rather than by themselves.

Nginx and TLS in front

The project's reverse-proxy guide puts host nginx on 127.0.0.1:3000 with Certbot. Nginx on the host is an ordinary process, so ufw drops its traffic, IPv4 and IPv6 alike, until the web ports are allowed.

  1. Point an A record (and an AAAA record if you added IPv6) at the server.
  2. Open the ports with ufw allow 80,443/tcp; install the packages with apt install nginx python3-certbot-nginx certbot -y.
  3. Create /etc/nginx/sites-available/linkwarden with the guide's server block: listen 80 and listen [::]:80, proxy_pass http://127.0.0.1:3000, the Host, X-Forwarded and Upgrade headers. Link it into sites-enabled, run nginx -t, reload.
  4. Run certbot --nginx -d app.example.com.
  5. In .env, set BASE_URL=https://app.example.com first, then NEXTAUTH_URL=https://app.example.com/api/v1/auth; then cycle the stack: docker compose down, and once that returns, docker compose up -d.

The docs also describe an optional second hostname, set with NEXT_PUBLIC_USER_CONTENT_DOMAIN, that serves preserved HTML from its own origin, keeping untrusted archived pages off the app's origin. If you would rather manage hosts and certificates from a web UI, see Nginx Proxy Manager on a dedicated server.

What the archive keeps, and how big it gets

A saved link goes into a queue; a background worker loads it in headless Chromium and writes whichever formats are enabled under Settings, Preferences, Archive Settings, globally or per tag.

FormatWhat is storedDefault size cap
ReadableArticle text, headings and images; feeds full-text search100 MB (READABILITY_MAX_BUFFER)
WebpageOne self-contained HTML file, made by Monolith100 MB (MONOLITH_MAX_BUFFER)
ScreenshotFull-page JPEG100 MB (SCREENSHOT_MAX_BUFFER)
PDFThe page printed to PDF100 MB (PDF_MAX_BUFFER)
Archive.orgNothing locally; the URL is submitted to the Wayback MachineOff for new accounts

A file over its cap is discarded rather than stored, and lowering the caps (the lighter-setup page uses 25) bounds what one bad page can add. Uploads you send yourself stop at NEXT_PUBLIC_MAX_FILE_BUFFER, 10 MB by default. Files live under ./data unless S3-compatible storage is configured. Full-text search over the readable text needs Meilisearch, which the compose file includes; our Meilisearch page covers that engine on its own. DISABLE_BROWSER=true turns preservation off and keeps plain bookmarks, tags and collections.

The server visits pages as an anonymous visitor, so a paywall or login wall is what it saves. For those, capture the page in your own browser with the extension's "Upload image from browser" option or with SingleFile.

AI tagging, the extension and the apps

  • AI tagging (since 2.9) generates tags, or assigns from your predefined ones, and is switched on under Settings, Preferences. The instance needs a provider in .env: Ollama (NEXT_PUBLIC_OLLAMA_ENDPOINT_URL, OLLAMA_MODEL; the docs name phi3:mini-4k as enough), an OpenAI-compatible endpoint, Azure OpenAI, Anthropic, OpenRouter or Perplexity. With no key set, the tagging loop never runs.
  • Inside the container, localhost is the container itself, so point the Ollama URL at an address the Linkwarden container can reach. With a hosted provider, the page content being tagged leaves your server; whether that suits you is a matter for that provider's own terms.
  • The browser extension comes from the Chrome Web Store (Chrome, Brave, Edge), Firefox Add-ons and, for Safari on macOS, the Mac App Store. On first use it wants three fields: the instance address, a username or email, and the password.
  • The mobile apps are for iOS and Android, with an APK in the project's builds repository. Log in with credentials, or generate a token under Settings, Access Tokens and use that. The site also installs as a PWA.

Backups, restore and updates

The upgrade guide's backup is a database dump made with pg_dump plus a tar of data and .env. Our nightly job below does the same with the dump in Postgres' custom format so pg_restore can read it. It is a one-line file: save it with a trailing newline. The archive holds .env, so it is as secret as the server; copy both files off the machine yourself, since a copy kept on those disks fails along with them. The meili_data folder is not in the docs' backup list, and not in ours.

# file: /etc/cron.d/linkwarden-backup  (our schedule: 03:30 daily, copies older than 14 days deleted)
30 3 * * * root umask 077; mkdir -p -m 700 /var/backups/linkwarden && cd /opt/linkwarden && docker compose exec -T postgres pg_dump -U postgres -Fc postgres > /var/backups/linkwarden/db-$(date +\%F).dump && tar -czf /var/backups/linkwarden/data-$(date +\%F).tar.gz data .env && find /var/backups/linkwarden -type f -mtime +14 -delete

Our restore checks the pair before anything is dropped. Be clear what that check proves: the files exist, are not empty and can be read; pg_restore --list reads the dump's table of contents, which does not prove the dump is complete or that it restores cleanly. Only a trial restore on another machine proves that.

cd /opt/linkwarden
B=/var/backups/linkwarden; D=2026-10-20   # the date of the pair you are restoring
if test -s "$B/db-$D.dump" && test -s "$B/data-$D.tar.gz" \
   && tar -tzf "$B/data-$D.tar.gz" > /dev/null \
   && docker compose exec -T postgres pg_restore --list < "$B/db-$D.dump" > /dev/null; then
  docker compose stop linkwarden
  docker compose exec -T postgres pg_restore -U postgres -d postgres --clean --if-exists < "$B/db-$D.dump"
  mv data "data.before-restore-$D" && tar -xzf "$B/data-$D.tar.gz" data
  docker compose up -d
else
  echo "No readable backup pair for $D: nothing was changed"
fi
  1. Check the changelog of every release between yours and the target; breaking changes are called out there.
  2. Take a backup: migrations run on their own at start-up and cannot be undone.
  3. Download the release with docker compose pull; next, docker compose up -d swaps the container. Follow the log with docker compose logs -f linkwarden until the app reports it is ready.
  4. To choose when you move, pin a tag such as ghcr.io/linkwarden/linkwarden:v2.16.3; a rollback is the old database plus the old tag.

Leave the Postgres image at the compose file's 16-alpine tag: a pull does not change its major version, and a major move is a dump and restore. Postgres 18 images expect the volume at /var/lib/postgresql; mounted at the old /var/lib/postgresql/data path, the container refuses to start. Also, every value in .env reaches all three containers through env_file, and docker inspect prints them, so treat the docker group as root.

Licence: AGPL-3.0

The licence of Linkwarden is the GNU AGPL, version 3.0 (LICENSE.md in the repository). Our general reading, which is not legal advice: running it unmodified for yourself or your company asks nothing of you. Change the code and offer the changed build to other people over a network, and those users are owed the source of your version. Distribute copies yourself, for example as a rebuilt image, and the duty is yours: you pass them on under that same licence and supply the source to whoever receives them.

Questions

Does a self-hosted instance lose features against the hosted plan?

The setup guide says self-hosters keep access to all the core features. The operating work is yours: the server, updates, backups and the optional pieces such as an AI provider or SMTP for password-reset email. Everything on this page is configured through the same .env file.

How much disk should I plan for?

The docs give the image size plus your data, and no per-link figure. Each preserved format is capped at 100 MB by default, so one pathological page cannot run away with the disk, and lower caps tighten that. Measure your own growth for a week and size from that, leaving room for local backup copies.

Can I run it as bookmarks only, without the archive?

Yes. DISABLE_BROWSER=true skips every browser-based job: you keep bookmarks, tags, collections, sharing and search over titles, URLs, descriptions and tags, and lose screenshots, PDFs, HTML copies, the readable view and previews. The lighter-setup page also shows how to drop Meilisearch.

Will the browser extension and the phone apps work with my own server?

Yes. The extension asks for your instance address on first use, and the apps accept a password or a token you generate in the web app. Point both at the HTTPS hostname you set up behind nginx.

How do I update Linkwarden in Docker?

Back up first, then pull the new image and recreate the container from the folder with the compose file. Database migrations run automatically on the first start and can make it slower than usual. Pin a version tag if you want to choose when to move and be able to roll back.

What do I need for AI tagging?

A provider set in .env: an Ollama instance the container can reach, or an API key for one of the hosted providers the docs list. Then tagging is enabled under Settings, Preferences. Without a provider the feature stays idle and costs nothing.

Pick the machine for your archive

Choose a line with NVMe and room for the archive to grow, then run the Compose stack on it with root and an unmetered port.

See the machines

Related on Server Room

Other use cases