Why keep your bookmarks on your own server?
Because a saved link is only useful while the page behind it still exists. Linkwarden saves the page too, so an article that moves or disappears is still readable in your collection, with your highlights and notes on it. It also does the usual bookmark jobs: collections you can share with a team, tags, and search.
Running it yourself costs nothing in licence fees (the mobile app page calls self-hosting free) and the project says self-hosters keep all the core features. In exchange, you look after the server: updates, backups and keeping sign-ups closed. The rest of this page covers each of those.
How much server does it need?
Less than you might expect in memory and processor, and more than you might expect in disk over time. From the project's own pages:
- Memory: 4 GB is "a comfortable starting point". Since version 2.15 a freshly started instance sits around 350 MB; the rest is room for the browser that saves pages.
- Processor: any two-core machine.
- Disk: roughly 1.5 GB taken by the image itself, then whatever you save.
- Images are built for both x86-64 and ARM64 processors.
The docs do not say how much one saved link takes, and it depends on the sites you keep. Our suggestion: use it normally for a week, check the folders with du -sh /srv/linkwarden/*, and plan your disk from that growth, plus space for backups.
One machine to avoid here: the ARM X-Gene cartridge. Its only choices are Ubuntu 16.04 and 18.04, and these steps expect a recent Ubuntu release and Docker Engine 28+. Choose an x86-64 server.
The server that fits
One of the small cartridge servers with NVMe storage is plenty for you or a small team; that is our own suggestion, not the project's. Pick a bigger single-processor machine if many people will save into it, or you want room for other apps. Browse them on our dedicated server page, where each card shows the cores, memory, disks and cities.
- Nobody else is on the hardware and you log in as root, so Docker and its firewall rules are yours to set.
- The port is unmetered. Every link you save is fetched again by your server, and a big import means thousands of page loads; none of it shows up on an invoice.
- A ready server is in your hands within 30 minutes of approval of your payment.
- Lock yourself out with a bad firewall rule and you can still reach the screen yourself on rack servers and blades, and on m510 or m710x cartridges. Other cartridges give you a power button but no screen.
- You manage the server; we keep the hardware and the network running.
Up and running in an evening
One thing to fix before you start. The official compose file lists the port as 3000:3000, which Docker publishes on every address the server has, 0.0.0.0 for IPv4 and [::] for IPv6. On IPv4, ufw's rules never see that traffic, because Docker routes it before they apply; the same goes for IPv6 once Docker's IPv6 support is turned on. So your firewall would not hide it. The block below changes it to 127.0.0.1:3000:3000, which, from Docker Engine 28 onward, can only be reached from the server itself. Before it adds a single firewall rule, it switches IPv6 support on in ufw, then lets SSH in and turns ufw on.
# run as root; Docker Engine 28 or newer and the Compose plugin must already be installed
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow OpenSSH
ufw --force enable
mkdir -p /srv/linkwarden && cd /srv/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
for v in NEXTAUTH_SECRET POSTGRES_PASSWORD MEILI_MASTER_KEY; do
sed -i "s|^$v=.*|$v=\"$(openssl rand -hex 32)\"|" .env
done
sed -i 's|- 3000:3000|- "127.0.0.1:3000:3000"|' docker-compose.yml
if grep -q '"127.0.0.1:3000:3000"' docker-compose.yml; then docker compose up -d
else echo "The port line did not change; fix docker-compose.yml first"; fi
- The loop writes a long random value into each of the three secrets the setup guide says you must change: the session secret, the database password and the search key. The guide also asks for quotes around values with special characters, which the loop adds.
- For a moment each value sits on sed's command line, where another user on the machine could spot it with
ps; your shell history only keeps theopenssltext. If others log in to this server, edit.envwithnanoinstead. - Do not touch the
NEXTAUTH_URLline yet. It changes once you have a domain.
Make yourself the admin before anyone else can
Linkwarden treats one account as the server administrator: the setting NEXT_PUBLIC_ADMIN, which defaults to user number 1. As we read it, on a new database that is whoever signs up first, so make sure it is you while the app is still private.
- On your own computer, run
ssh -L 127.0.0.1:3000:127.0.0.1:3000 you@203.0.113.10(your server's address) and leave it open. It carries the page to you over your own login. - Open
http://localhost:3000in your browser and create your account. - Back on the server, in
/srv/linkwarden, change the registration line toNEXT_PUBLIC_DISABLE_REGISTRATION=trueand apply it withdocker compose up -d. - Try the sign-up page again. It should now turn new accounts away.
With sign-ups closed, you add people yourself: according to the project's source code (postUser.ts, plus a user-administration page under the admin area), the admin can make new users even when registration is switched off.
Opening it to the web with HTTPS
Linkwarden's reverse-proxy guide uses nginx and Certbot on the server itself, sending traffic to 127.0.0.1:3000. Because that nginx is a normal program rather than a Docker port, ufw keeps visitors out on both IPv4 and IPv6 until you let web traffic through.
- Add a DNS A record for a name such as
links.example.com, plus an AAAA record if your server has IPv6 (free to add when you order). - Let web traffic in:
ufw allow 80,443/tcp. Install the tools:apt-get install -y certbot python3-certbot-nginx nginx. - Copy the server block from the guide into
/etc/nginx/sites-available/links, put your name in it, enable it, then runnginx -tand reload nginx. - Get a certificate:
certbot --nginx -d links.example.com. - Edit
.env:NEXTAUTH_URL=https://links.example.com/api/v1/auth, andBASE_URLset to the same address without the path. Stop it withdocker compose down, start it withdocker compose up -d.
For extra safety, the docs show how to serve saved web pages from a second name via NEXT_PUBLIC_USER_CONTENT_DOMAIN, so archived HTML stays away from the address you log in on. Prefer clicking to editing files? Nginx Proxy Manager can stand in for the hand-written nginx setup.
Saving whole pages, not just links
When you save a link, it joins a queue. A worker on your server visits it with an actual browser and keeps the formats you have switched on in Settings, Preferences, Archive Settings:
- Readable: the article alone, without menus and ads. Its text is also what full-text search looks through, using the Meilisearch container (more on that engine in our Meilisearch guide).
- Webpage: one self-contained HTML file with styles and images.
- Screenshot: a JPEG of the full page.
- PDF: the page printed to a PDF.
- Archive.org: off by default; it only sends the address to the Wayback Machine.
Each format has a size ceiling of 100 MB by default, and anything bigger is thrown away rather than kept; you can lower the limits in .env. Files you upload yourself are capped at 10 MB unless you raise NEXT_PUBLIC_MAX_FILE_BUFFER. The docs call Monolith (the full webpage copy) and PDF the heaviest formats, so turning those off is the easiest saving. For a page behind a login, the server sees what a stranger sees; capture it in your own browser instead.
The AI tagging feature (added in 2.9) can tag new links for you, freely or from tags you define, once you switch it on in Settings, Preferences. Your server needs a provider in .env first: Ollama with a small model (the docs mention phi3:mini-4k), or a key for Anthropic, Azure OpenAI, OpenRouter, Perplexity or any OpenAI-compatible service. If Ollama runs elsewhere on the machine, give an address the container can reach, since localhost inside the container is the container. With an outside provider, the content being tagged goes to that provider, so check its terms for yourself.
To save from anywhere, there is a browser extension (Chrome Web Store for Chrome, Brave and Edge; Mozilla's add-on store; the Mac App Store for Safari). The first time, you give it your server's address and your login. Official iOS and Android apps accept your normal login too, or a token you create on the Access Tokens page in Settings, and the site installs as a home-screen app as well.
Backups and updates you can trust
Linkwarden's upgrade guide backs up two things: the database with pg_dump, and the data folder with your .env. Our nightly cron file does both, dumping in a format pg_restore can read, and keeps ten days. Save it exactly as one line with a newline after it. Because the archive contains .env and its secrets, the folder is private; copy the files to another machine regularly, because if that disk dies, so do copies sitting on it. The search index in meili_data is not on the guide's list, and our job leaves it out as well.
# file: /etc/cron.d/linkwarden-nightly (our choice: 04:15 every night, 10 days of copies)
15 4 * * * root umask 077; mkdir -p -m 700 /root/linkwarden-backups && cd /srv/linkwarden && docker compose exec -T postgres pg_dump -U postgres -Fc postgres > /root/linkwarden-backups/db-$(date +\%F).dump && tar -czf /root/linkwarden-backups/files-$(date +\%F).tar.gz data .env && find /root/linkwarden-backups -type f -mtime +10 -delete
To restore, our block below looks before it drops anything: both files must exist and be readable, and pg_restore --list must be able to read the dump's contents list. That shows the file is readable, not that it is complete or will restore without errors. Restoring it once on a second, throwaway server is the only real proof.
cd /srv/linkwarden
K=/root/linkwarden-backups; DAY=2026-10-20 # the night you want back
if test -s "$K/db-$DAY.dump" && test -s "$K/files-$DAY.tar.gz" \
&& tar -tzf "$K/files-$DAY.tar.gz" > /dev/null \
&& docker compose exec -T postgres pg_restore --list < "$K/db-$DAY.dump" > /dev/null; then
docker compose stop linkwarden
docker compose exec -T postgres pg_restore -U postgres -d postgres --clean --if-exists < "$K/db-$DAY.dump"
mv data "data.old-$DAY" && tar -xzf "$K/files-$DAY.tar.gz" data
docker compose up -d
else
echo "Backup for $DAY is missing or unreadable, so nothing was touched"
fi
Updating, the way the guide describes it: look through the changelogs of each version you are skipping, take a backup (database changes on start-up cannot be undone), run docker compose pull, and when it finishes, docker compose up -d. Then keep an eye on the app log (docker compose logs -f linkwarden); once it reports ready, you are done. Prefer to choose your moment? Pin a release such as v2.16.3 in the image line; going back means restoring the old database and pinning the old tag.
Keep the database image on the 16-alpine tag the compose file uses; pulling never jumps a major Postgres version, and a major jump needs a dump and restore. Postgres 18 images keep data under /var/lib/postgresql, and with the volume still mounted at /var/lib/postgresql/data they refuse to start. One more thing: all three containers receive every .env value, and docker inspect shows them to any user allowed to run it, so only trusted admins belong in the docker group.
What the AGPL licence means for you
Linkwarden's repository carries the AGPL (GNU Affero GPL v3). Read this as a general summary, not legal advice. Using it as published, for yourself or your team, puts no duties on you. Change the code and let others use your changed version over the network, and you must offer those users your source. Hand out copies yourself, changed or not, and you must pass them on under the same AGPL licence and give the people who receive them the source code.
Questions people ask
- Is self-hosting Linkwarden free?
- The software costs nothing to run yourself; the project's mobile app page describes self-hosting as free, with the work of deploying and maintaining it on you. Your costs are the server and your time.
- Can I add family or colleagues once sign-ups are closed?
- Yes. The project's source code lets the admin create users with registration switched off. That keeps strangers out while you decide exactly who gets an account.
- What if I only want bookmarks, not page copies?
- Set
DISABLE_BROWSER=truein.env. Links, tags and collections stay, and so does sharing; search still covers titles, addresses, descriptions and tags. You give up screenshots, PDFs, webpage copies and readable views. The docs show this as the lightest way to run it. - Do the phone apps and the extension need anything special?
- Only your server's address and an account. Give the extension your HTTPS address on first use, and sign in to the apps with your login or a token from Settings. Set up HTTPS first so they connect to the public name.
- How do I upgrade the Docker install safely?
- Take a backup, pull the new image and start it again. The first start after an upgrade applies database changes, which can take longer than usual and cannot be reversed, which is why the backup comes first.