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.
| Resource | Figure in the docs | What drives it |
|---|---|---|
| Memory | 4 GB as a comfortable start; about 350 MB idle | Headless Chromium during preservation, then Postgres and Meilisearch |
| CPU | Any 2-core machine | Monolith and PDF jobs are the costliest formats |
| Disk | Image near 1.5 GB; data on top | Preserved files under ./data, the database, the search index |
| Architecture | Images for x86-64 and ARM64 | Docker 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_SECRETsigns sessions, JWTs and preserved-content tokens;POSTGRES_PASSWORDbuilds the internalDATABASE_URL;MEILI_MASTER_KEYis the search engine key. The guide says all three must be changed and must differ, and to quote values with special characters.- Keep
NEXTAUTH_URLon 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
pscan 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 intonano .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.
- On your workstation, start a tunnel through your own login:
ssh -N -L 3000:127.0.0.1:3000 you@server.example.net. - Browse to
http://localhost:3000, which matches the defaultNEXTAUTH_URL, and sign up. - On the server, put
NEXT_PUBLIC_DISABLE_REGISTRATION=trueinto.env(for examplesed -i 's/^NEXT_PUBLIC_DISABLE_REGISTRATION=.*/NEXT_PUBLIC_DISABLE_REGISTRATION=true/' .env), and apply it:docker compose up -drecreates the app container. - 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.
- Point an A record (and an AAAA record if you added IPv6) at the server.
- Open the ports with
ufw allow 80,443/tcp; install the packages withapt install nginx python3-certbot-nginx certbot -y. - Create
/etc/nginx/sites-available/linkwardenwith the guide's server block:listen 80andlisten [::]:80,proxy_pass http://127.0.0.1:3000, the Host, X-Forwarded and Upgrade headers. Link it into sites-enabled, runnginx -t, reload. - Run
certbot --nginx -d app.example.com. - In
.env, setBASE_URL=https://app.example.comfirst, thenNEXTAUTH_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.
| Format | What is stored | Default size cap |
|---|---|---|
| Readable | Article text, headings and images; feeds full-text search | 100 MB (READABILITY_MAX_BUFFER) |
| Webpage | One self-contained HTML file, made by Monolith | 100 MB (MONOLITH_MAX_BUFFER) |
| Screenshot | Full-page JPEG | 100 MB (SCREENSHOT_MAX_BUFFER) |
| The page printed to PDF | 100 MB (PDF_MAX_BUFFER) | |
| Archive.org | Nothing locally; the URL is submitted to the Wayback Machine | Off 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 namephi3:mini-4kas enough), an OpenAI-compatible endpoint, Azure OpenAI, Anthropic, OpenRouter or Perplexity. With no key set, the tagging loop never runs. - Inside the container,
localhostis 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
- Check the changelog of every release between yours and the target; breaking changes are called out there.
- Take a backup: migrations run on their own at start-up and cannot be undone.
- Download the release with
docker compose pull; next,docker compose up -dswaps the container. Follow the log withdocker compose logs -f linkwardenuntil the app reports it is ready. - 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.