Skip to content

self-hosting

SaaS is Temporary. Your Reading List Doesn’t Have to Be.

Self-hosting a Pocket alternative using Readeck, Linkwarden, and Docker Swarm.

A light gray abstract background with soft curved shapes features five tech logos arranged across the image. At the top center is the Wallabaga logo, a dark square with a stylized lightning-like symbol. To the right is the Linkwarden logo, composed of blue concentric arcs forming a circular signal pattern. On the left is the Readeck logo, a geometric ribbon-like “R” in teal and yellow. At the bottom left is the FreshRSS logo, a minimalist black bird icon. At the bottom right is the Docker Swarm logo, a blue whale carrying stacked container blocks on its back.

There's a pattern that repeatedly plays out in the consumer web. A service launches, it's great, people love it, it grows. Then the economics shift, there's an acquisition, or it takes a one-way trip to the Google Graveyard. The unceremonius demise of Google Reader in 2013 was my first experience with this phenomena. So, when Pocket was acquired by the Mozilla Corporation, a wholly owned taxible subsidiary of the Mozilla Foundation, I knew its days were numbered.

The promise of cloud-hosted SaaS is convenience and the illusion that someone else will handle the hard parts. What happens when, inevitably, they decide to close up shop? Fortunately, the very same non-profit Mozilla Foundation believed in an open and extensible web. A few of it's pioneering members gave us the RSS protocol to ensure our ability to share and disseminate information wasn't at the whim of any one for-profit enterprise.

Flash forward to 2015, two years after Google Reader bit the dust. The first major release of FreshRSS, a free and open source feed aggregator, was published on Github. I was still using Feedly and tolerating advertisements within my personally curated feeds. I'm not sure when I migrated to FreshRSS, but I didn't start self-hosting services until 2019. If you look at Feedly now, they've pivoted to AI and claim you can "Use Feedly AI to find, synthesize, and share industry intelligence faster".

Even though I use FreshRSS, most of the time time I am just scanning headlines and intro ledes. I needed a place to come back to for quick, focused reading. The open source community has many options, but for me it came down to these three: Wallabag, Readeck and LinkWarden.

The setup I ended up with is pretty simple:

  1. FreshRSS — skim headlines, pick out anything worth a closer look
  2. Readeck — sit down later and actually read it without distractions
  3. Linkwarden — save the rare things that are worth keeping long-term

Both services run on my Docker Swarm cluster alongside everything else. Getting them there turned out to be more involved than expected.

What follows is all the fiddly stuff you don’t see when you pay someone a few dollars a month to handle it for you.


The Stack Files

The pattern I use across services is pretty consistent: inject secrets via a shell entrypoint wrapper, route everything through Traefik, and keep services internal-only.

My phone is connected over WireGuard, so everything is still accessible remotely without exposing it publicly.

Readeck

services:
  readeck:
    image: codeberg.org/readeck/readeck:latest
    user: "0"
    secrets:
      - util_readeck_db_password
    environment:
      READECK_SERVER_PORT: "8080"
      READECK_MAIL_HOST: "10.20.30.40"
      READECK_MAIL_PORT: "2525"
      READECK_MAIL_FROM: "[email protected]"
      READECK_MAIL_FROMNOREPLY: "[email protected]"
    entrypoint:
      - /bin/sh
      - -c
      - |
        set -eu
        DB_PASS="$(tr -d '\n' < /run/secrets/util_readeck_db_password)"
        export READECK_DATABASE_SOURCE="postgres://readeck_app:[email protected]:5432/readeck_prod?sslmode=disable"
        exec /bin/readeck serve

Linkwarden

services:
  linkwarden:
    image: ghcr.io/linkwarden/linkwarden:latest
    secrets:
      - util_linkwarden_db_password
      - util_linkwarden_nextauth_secret
    environment:
      NEXTAUTH_URL: https://links.home.clifmo.com
      NEXT_PUBLIC_DISABLE_REGISTRATION: "true"
      NEXT_PUBLIC_CREDENTIALS_ENABLED: "true"
      EMAIL_FROM: "[email protected]"
      EMAIL_SERVER: "smtp://10.20.30.40:2525"
    entrypoint:
      - /bin/sh
      - -c
      - |
        set -eu
        DB_PASS="$(tr -d '\n' < /run/secrets/util_linkwarden_db_password)"
        export DATABASE_URL="postgresql://linkwarden_app:[email protected]:5432/linkwarden_prod"
        export NEXTAUTH_SECRET="$(tr -d '\n' < /run/secrets/util_linkwarden_nextauth_secret)"
        exec docker-entrypoint.sh sh -c 'yarn prisma:deploy && yarn concurrently:start'

Challenge 1: The Readeck Binary That Wasn't

First run of Readeck:

/bin/sh: exec: line 8: /readeck: Permission denied

Adding user: "0" to run as root made no difference. Inspecting the image:

docker run --rm --entrypoint /bin/sh codeberg.org/readeck/readeck:latest \
  -c 'ls -la /'
drwxr-xr-x    2 root     root          4096 Mar 28 readeck

/readeck is a directory — the app’s data folder. The actual binary lives at /bin/readeck.

My entrypoint was trying to run a directory.

The database DSN also needed fixing. I initially used the key-value Postgres format:

host=db01.home.clifmo.com port=5432 dbname=readeck_prod ...

Readeck infers the driver from the URL scheme, so it expects a full connection string:

postgres://readeck_app:[email protected]:5432/readeck_prod?sslmode=disable

Note: postgresql:// also fails — it has to be postgres://.


Challenge 2: Linkwarden's Registration Form

Linkwarden doesn’t create a default admin account. Instead, the first registered user becomes admin. This is fine, except the registration form had no email field, and submitting it returned:

Error: Invalid input: expected string, received undefined [email]

This is a known issue (#225) caused by Next.js baking NEXT_PUBLIC_* variables at build time, not runtime. The prebuilt image on ghcr.io was compiled with NEXT_PUBLIC_EMAIL_PROVIDER set, which changes the auth flow to expect a magic-link email field. And since the form doesn't render it the backend received it as undefined.

The maintainers fixed this in early 2025, but the latest image still showed the behavior. Rather than building the image locally, I just seeded the first user directly in Postgres.

Generating the Password Hash

Linkwarden uses bcrypt. The naive approach - passing the password as a shell argument — silently breaks special characters:

# WRONG — printf interprets %b as an escape sequence
printf '%boIJrbkdeiQT' > /tmp/pw
# result: oIJrbkdeiQT  ← leading %b eaten

The fix is to use echo -n with single quotes, which passes the string literally:

docker run --rm --entrypoint /bin/sh ghcr.io/linkwarden/linkwarden:latest -c '
echo -n '"'"'%boIJrbkdeiQT'"'"' > /tmp/pw
node -e "
const bcrypt = require(\"bcrypt\");
const fs = require(\"fs\");
const pass = fs.readFileSync(\"/tmp/pw\", \"utf8\");
bcrypt.hash(pass, 10).then(h => console.log(h));
"'

This runs the hash inside the Linkwarden container itself, using the same bcrypt library the app uses — no version mismatches possible.

Inserting the User

docker exec -it $(docker ps -qf name=db_postgres) psql -U postgres linkwarden_prod
INSERT INTO "User" (name, username, email, password, locale, "linksRouteTo", "aiTaggingMethod", "collectionOrder")
VALUES (
  'clifmo',
  'clifmo',
  '[email protected]',
  '$2b$10$GBkBkzK33TlRrOU5E5lAvuYC7rH/RpS1wku16wQrm6H1g.Obfv146',
  'en',
  'ORIGINAL',
  'DISABLED',
  '{}'
);

Then mark the email verified (skipping the verification email entirely):

UPDATE "User" SET "emailVerified" = NOW() WHERE username = 'clifmo';

Login with [email protected] and the password. After that, flip NEXT_PUBLIC_DISABLE_REGISTRATION back to "true" and redeploy.


Email Configuration

Both services use the internal Postfix relay at 10.20.30.40:2525 with no auth and no TLS because it's on the overlay network.

Readeck uses READECK_MAIL_* env vars. It has no concept of a separate driver setting, the host and port are enough.

Linkwarden uses Next Auth's EMAIL_SERVER format, which is a standard SMTP URL:

smtp://10.20.30.40:2525

One thing to avoid: setting NEXT_PUBLIC_EMAIL_PROVIDER=nodemailer enables magic-link login as an auth method, which conflicts with credentials-based signup. Leave that unset and just configure EMAIL_SERVER + EMAIL_FROM for transactional emails (password reset, etc.).


The Workflow in Practice

With both services running, the glue is a pair of FreshRSS extensions.

xExtension-readeck-button adds a one-click “send to Readeck” action directly in the article view. On the other side, xExtension-ShareToLinkwarden handles long-term archiving via the share menu.

Once everything is wired up and deployed, it just works. It’s not as polished as a hosted service and getting here took more effort than I’d like to admit. But it's mine. I can freely contribute to any of these project to make the changes I'd like to see. And it won't dissapear next quarter. I’ll take that over convenience any day.

End of article