Skip to main content

Running a Lighter Setup

Linkwarden used to be heavy. The image sat at roughly 3.0 GB, and a freshly started instance used around 700 MB of memory before doing any work at all.

That changed in 2.15. The Docker image was rewritten and dropped to about 1.5 GB, and reworking how the app and the archiving browser are started brought idle memory down to about 350 MB. If you are running an older version, upgrading is the single biggest improvement available to you.

We are not done, and these numbers should keep coming down. What is left is mostly preservation: Linkwarden ships a headless browser, a Rust HTML archiver, a search engine, and a database, because it saves a copy of every page you bookmark rather than just its address. That is a real cost, but it is one you can opt out of, in whole or in part.

This guide which settings actually reduce disk, memory, and CPU usage. Everything here is optional. The defaults are already reasonable, so treat the rest as tuning for your hardware rather than repairs.

Making It Lighter

Apply the environment variables below in your .env file, then run docker compose up -d to restart with the new values.

1. Turn off the preserved formats you do not use

Effect: large. Saves CPU, disk, and time.

In the app, go to Settings → Preferences → Archive Settings and disable any format you do not need. Individual tags can also override these, so you can keep full preservation for a few tags and skip it for everything else.

Monolith and PDF are the most expensive formats. Screenshots are cheaper. Readable text is the cheapest and is what search and highlights depend on.

Note that a preview image is always generated, so the browser still runs even with every format off.

2. Disable browser-based preservation entirely

Effect: largest. Keeps the instance near its idle memory floor.

DISABLE_BROWSER=true

Every worker task that needs the browser is skipped and links are marked as having no preserved formats. You keep bookmarks, tags, collections, sharing, and search over titles, URLs, descriptions, and tags. You lose screenshots, PDFs, HTML snapshots, readable view, and preview images.

The worker still briefly starts Chromium while it drains links that were already queued, and closes it 60 seconds after the queue empties.

Effect: medium. Flattens memory spikes at the cost of throughput.

ARCHIVE_TAKE_COUNT=1

This caps how many links are preserved concurrently, and also how many are auto-tagged per batch. A backlog takes longer to clear, but the peak stays low and predictable.

4. Constrain the allocators

Effect: medium. Lowers idle and steady-state memory.

MALLOC_ARENA_MAX=2
NODE_OPTIONS=--max-old-space-size=400

MALLOC_ARENA_MAX limits how many per-thread memory pools glibc creates. The default scales with CPU count and is generous for a workload like this, so capping it at 2 reclaims a noticeable chunk of resident memory on multi-core machines.

--max-old-space-size caps the V8 heap in MB, which makes garbage collection kick in earlier instead of letting the heap grow toward the process default. Setting it too low causes crashes on large pages or large imports, so treat 400 as a floor for a preservation-enabled instance and raise it if you see out-of-memory restarts. It applies to both the web and worker processes.

5. Keep the browser on-demand

Effect: small, but already the default.

BROWSER_LIFECYCLE=on-demand

on-demand (the default) starts Chromium when work arrives and closes it when the queue has been empty for 60 seconds. The alternative, persistent, keeps one browser alive for the life of the worker and restarts it every 30 minutes. Persistent trades steady memory for lower per-batch latency, so it only makes sense on a busy instance with memory to spare.

6. Move the browser off the machine

Effect: large on the Linkwarden host, since the heaviest component is gone.

PLAYWRIGHT_WS_URL=ws://browser-host:3000

The worker connects to a remote Chromium over CDP instead of launching one locally. This is useful when Linkwarden runs on a small always-on device and you have a beefier machine available for the browser work.

Page requests are then fetched by the remote browser, which resolves DNS on its own, so run it on a network segment that cannot reach anything sensitive. See the note on PLAYWRIGHT_WS_URL in Environment Variables.

7. Drop MeiliSearch

Effect: medium. Removes a container and its on-disk index.

MeiliSearch is optional. Remove the service from your docker-compose.yml along with the meilisearch entry under depends_on, delete the meili_data folder, and leave MEILI_MASTER_KEY empty:

services:
linkwarden:
# ...
depends_on:
- postgres
# meilisearch service removed

Search then falls back to PostgreSQL, matching against link titles, URLs, descriptions, and tag names. You lose the advanced search operators and full-text search across preserved page content.

8. Quiet the background loops

Effect: small, but free if you do not use these features.

NEXT_PUBLIC_RSS_POLLING_INTERVAL_MINUTES=1440

RSS polling runs hourly by default even with no subscriptions. Raising the interval reduces periodic wake-ups.

AI tagging costs nothing when unconfigured: with no provider key set, the auto-tagging loop exits at startup and never runs.

9. Cap disk growth

Effect: disk only.

PDF_MAX_BUFFER=25
SCREENSHOT_MAX_BUFFER=25
MONOLITH_MAX_BUFFER=25
TEXT_CONTENT_LIMIT=50000

The *_MAX_BUFFER values are size limits in MB. A generated file larger than its limit is discarded instead of stored, so these bound how much a single pathological page can add to your storage folder. They are checked after generation, so they cap disk usage rather than peak memory.

TEXT_CONTENT_LIMIT caps how many characters of extracted readable text are stored per link in PostgreSQL, which is the main way the database itself grows.

10. Put hard limits on the containers

Effect: containment, not reduction.

services:
linkwarden:
# ...
mem_limit: 1g
cpus: 1.5

Limits do not make Linkwarden use less memory, they stop it from affecting the rest of the machine. If the limit is set below what a workload actually needs, the container is killed and restarted mid-preservation, so combine this with the settings above rather than using it on its own.

Example Configurations

Bookmarks only

Lowest possible footprint. No preservation, no search engine, one database container.

DISABLE_BROWSER=true
MALLOC_ARENA_MAX=2
MEILI_MASTER_KEY=

Plus removing the meilisearch service from docker-compose.yml. This keeps the instance close to the ~350 MB idle mark.

Light preservation

Full preservation on a small server, tuned for low peaks instead of speed.

ARCHIVE_TAKE_COUNT=1
MALLOC_ARENA_MAX=2
NODE_OPTIONS=--max-old-space-size=400
BROWSER_TIMEOUT=3

BROWSER_TIMEOUT is the hard cap in minutes for a single preservation job, so lowering it stops one slow page from holding a browser page open for five minutes.

Optionally turn off Monolith and PDF in Settings → Preferences → Archive Settings and keep screenshots and readable view, which covers most of what people actually revisit.

Reading Memory Usage Correctly

After a heavy workload, such as an import or a large preservation backlog, memory settles at a higher number than it was before, and it stays there.

This is expected and is not a leak. Both the JavaScript runtime and the system allocator keep memory they have already claimed from the kernel so it can be reused for the next workload instead of being requested again. From outside the container it looks like memory that is never given back, because in practice it is not given back until the process exits. What matters is that the number plateaus rather than climbing with every batch.

Two things follow from that:

  • Compare setups from a fresh start. Restart the container, let it settle, and read the idle number with docker stats before running any work. Comparing a freshly started instance against one that imported 10,000 links yesterday tells you nothing about the settings you changed.
  • Size the machine for the peak, not the idle floor. The ~350 MB idle figure is what a fresh instance uses. An instance that regularly preserves links will sit well above that, and MALLOC_ARENA_MAX=2 plus a NODE_OPTIONS=--max-old-space-size=400 cap is how you keep that plateau lower.

If the number keeps climbing across days of light use and never plateaus, that is worth reporting. A plateau after a busy period is not.