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:
- FreshRSS — skim headlines, pick out anything worth a closer look
- Readeck — sit down later and actually read it without distractions
- 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 bepostgres://.
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=nodemailerenables magic-link login as an auth method, which conflicts with credentials-based signup. Leave that unset and just configureEMAIL_SERVER+EMAIL_FROMfor 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.