Can I self-host Instapaper?

YES · ONE EVENING— setup effort 2 of 4

YES — it's called wallabag. It takes one prompt, a 1024 MB VPS, and about 90 minutes. That is $5.99 a month you stop paying Instapaper — $71.88 a year on the Premium plan.

Why people pay for Instapaper

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

Instapaper sells the calmest reading surface anyone has built for the web, and then sells the memory behind it. The free tier already saves unlimited articles and syncs them across devices; Premium is bought for full-text search, a permanent archive of the pages themselves, and the text-to-speech and Kindle handoff that turn a saved article into something you can finish while driving. What you are really paying for is that the copy is still there in five years, which is a promise about the company rather than about the software.

Instapaper plans and list prices
PlanList priceWhat it buys
FreefreeUnlimited saved articles, sync across devices, folders and third-party app access, with notes capped at five a month.
Premiumthe plan this page prices against$5.99/moMonthly-billed figure. The page also sells it at $59.99 a year, which works out at about $5.00 a month. Adds full-text search, the permanent archive, unlimited notes, PDF reading, Kindle delivery, speed reading and an ad-free site.

Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: high

Replaced by wallabag

One project, named before the prompt, so you know what you are about to install.

Saves the readable article, not the link, and hands it to the phone or e-reader you actually read on.

The only one of these built for the same job, which is reading later rather than filing links. It keeps its own parsed copy of every article, so a page that goes behind a paywall or disappears is still readable, and it has the client story that makes a read-later tool usable at all: an Android app, browser extensions, an ePub and Kindle-format export, and a plugin shipped inside KOReader so a Kobo or Kindle running it syncs directly. It also ships an importer that takes the CSV export from your Instapaper settings page, so the move is an upload rather than a rewrite. What you give up is Instapaper's text-to-speech and its ad-free hosted reader, and what you take on is a PHP application that rebuilds its cache on every restart and makes you wait for it. Worth remembering while you compare: Omnivore's hosted read-later service closed in November 2024 and the software carried on as a self-hosted-only project, which is the entire argument for a reading list you can run yourself.

The swap

You're paying

Instapaper

$5.99/mo · $71.88/yr

is replaced by

You'd run

wallabag

ONE EVENING · ~90 min to running · 1024 MB RAM

Instapaper Premium · vendor list price · checked 2026-08-06 · source

Before you start

RAM floor
1024 MBfloor from upstream docs — not measured by us yet
Disk
5 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~90 min1–3 hours, through the first backup

The prompt

Two paths to the same wallabag: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

316 lines · 14,964 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install wallabag 2.6.14 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server, and the same hostname becomes
`SYMFONY__ENV__DOMAIN_NAME`, the string wallabag puts in every feed URL and share link.

wallabag needs 1024 MB of RAM available and 5 GB free on /srv. The image publishes amd64,
arm64 and armv7. Measure all four before installing anything:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 1024 MB or free disk is under 5 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot get a
certificate for a name that does not resolve.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/wallabag /srv/wallabag/backups
sudo install -d -m 750 -o 65534 -g 65534 /srv/wallabag/data /srv/wallabag/images
ls -la /srv/wallabag
```

Assert: `ls -la` shows `backups` owned by the login user, and `data` and `images` owned by
`nobody`. That owner is not decoration: the image chowns /var/www/wallabag to uid 65534 when it
is built and runs php-fpm as that user, so a directory owned by anyone else is one wallabag
cannot write to. Nothing for this service is written outside /srv/wallabag.

## 3. Secrets

Two secrets, both generated here on the server: the Symfony application secret and the password
that replaces the one the image ships with. Do not print either, do not repeat them in your
summary, and do not put them in any log line.

```bash
umask 077
cat > /srv/wallabag/.env <<EOF
SYMFONY__ENV__DOMAIN_NAME=https://<DOMAIN>
SYMFONY__ENV__SECRET=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -base64 24)
EOF
chmod 600 /srv/wallabag/.env
umask 022
ls -l /srv/wallabag/.env
```

Assert: the file exists with mode `-rw-------`. Replace `<DOMAIN>` on the first line with the
real hostname before running the block. The application secret matters because the image ships
a default value for it in its parameter template, so every wallabag that never set one signs
its remember-me cookies with a string published on GitHub. `ADMIN_PASSWORD` is read by step 7
from inside the container; the user reads it with
`sudo grep ADMIN_PASSWORD /srv/wallabag/.env`.

## 4. compose.yml

```bash
cat > /srv/wallabag/compose.yml <<'EOF'
# wallabag · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   image README ....... https://github.com/wallabag/docker/blob/master/README.md
#   parameter template . https://github.com/wallabag/docker/blob/master/root/etc/wallabag/parameters.template.yml
#   entrypoint ......... https://github.com/wallabag/docker/blob/master/root/entrypoint.sh
#   image definition ... https://github.com/wallabag/docker/blob/master/Dockerfile
#   parameter reference  https://doc.wallabag.org/admin/parameters/
#
# One service. The image runs nginx and php-fpm side by side and keeps every
# article in the SQLite file upstream ships as the default driver, at
# data/db/wallabag.sqlite. The two bind mounts are the two paths the upstream
# README names as worth keeping. The image chowns /var/www/wallabag to nobody,
# uid 65534, at build time, so both host directories are created with that owner
# and the login user reads them through sudo. Tag and digest were read from
# Docker Hub on 2026-08-06; the image publishes amd64, arm64 and armv7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  wallabag:
    image: wallabag/wallabag:2.6.14@sha256:4a527e027e0d59e87c14225ef11e005af3d4890374202ad319ce5e63dfc66709
    container_name: wallabag
    restart: unless-stopped
    env_file: /srv/wallabag/.env
    environment:
      # SQLite is the image default and it is the whole database here: one file
      # under data/db, with no second container to run and no dump to schedule.
      SYMFONY__ENV__DATABASE_DRIVER: pdo_sqlite
      # Public sign-up stays off. It is already off in the image, and writing it
      # here means a reviewer can see the posture without opening .env.
      SYMFONY__ENV__FOSUSER_REGISTRATION: "false"
      # The issuer name an authenticator app shows if you enable two-factor.
      SYMFONY__ENV__SERVER_NAME: wallabag
      # Upstream's default is 128M. Saving an article parses a whole page with
      # tidy and DOM, and 128M is where long pages start failing.
      PHP_MEMORY_LIMIT: 256M
    volumes:
      - /srv/wallabag/data:/var/www/wallabag/data
      - /srv/wallabag/images:/var/www/wallabag/web/assets/images
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8109.
      - "127.0.0.1:8109:80"
    # The image already carries a HEALTHCHECK that polls /api/info, so there is
    # no healthcheck block here to drift away from it.
EOF
cd /srv/wallabag && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. There is no database service because there is no database
process: SQLite is the driver upstream ships as the default and it lives in one file under
data/db. That is why this install is one container.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-wallabag
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# wallabag · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/wallabag/docker/blob/master/root/etc/nginx/nginx.conf and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also SYMFONY__ENV__DOMAIN_NAME in .env, and wallabag builds its feed and
# sharing links from it, so the two have to stay the same string.

<DOMAIN> {
	# The nginx inside the container maps X-Forwarded-Proto onto the HTTPS
	# fastcgi parameter, and Caddy sets that header on every proxied request,
	# so PHP sees an https request and wallabag generates https links.
	# Nothing else has to be configured for that to happen.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		# Every article page links out to the site it was saved from. Without
		# this, each of those sites learns the hostname of a private reading
		# list and roughly what is in it.
		Referrer-Policy "no-referrer"
		-Server
	}

	# Article pages are HTML and the reading view is text, so compression is
	# worth more here than it is in front of an API.
	encode zstd gzip

	# 8109 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8109
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-wallabag, reload, and report what it objected to. Caddy requests
the certificate itself and renews it on its own, so there is nothing to schedule.

## 6. Firewall

Two ports open, both Caddy's. The commands are idempotent, so on a box Prompt Zero configured
they change nothing:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, and
443/udp is HTTP/3. 8109 stays closed because compose binds it to 127.0.0.1, so Caddy is the
only path in and a rule for 8109 would widen that. Assert: `ufw status verbose` prints
`Status: active`, shows 80, 443/tcp and 443/udp, and no rule for 8109.

## 7. Start and verify

Read this first. The image creates its first account on first boot with a username and a
password that are both the word wallabag, documented in its README, and that account is a super
admin. The password change below is not tidying up, it closes a published credential on a host
that already resolves, so run the block in one go.

```bash
cd /srv/wallabag
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8109/api/info); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
docker compose exec -T wallabag su -c '/var/www/wallabag/bin/console fos:user:change-password wallabag "$ADMIN_PASSWORD" --env=prod' -s /bin/sh nobody
curl -sS https://<DOMAIN>/api/info
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/register
curl -sS https://<DOMAIN>/login | grep -c 'Log in'
```

Assert, all five, and print what you received. The loop ends on `200`, after a column of `000`
while the first boot rebuilds the Symfony cache. The console prints
`Changed password for user wallabag`. `/api/info` contains `"version":"2.6.14"` and
`"allowed_registration":false`. `/register` prints `301`, a sign-up attempt sent back to the
login page because public registration is off, and that is the security assert here. The last
prints at least `1`.

Now prove the shipped credential is dead. Server or user's machine, either works:

```bash
shipped=wallabag
jar=$(mktemp)
tok=$(curl -sS -c "$jar" https://<DOMAIN>/login | sed -n 's/.*name="_csrf_token" value="\([^"]*\)".*/\1/p' | head -1)
echo "csrf token length ${#tok}"
curl -sS -b "$jar" -c "$jar" -L -d "_username=$shipped" -d "_password=$shipped" -d "_csrf_token=$tok" https://<DOMAIN>/login_check | grep -c 'unread/list' || true
rm -f "$jar"
```

Assert: the token length is not `0` and the last line prints `0`. A successful login lands on
the unread list, whose HTML carries `unread/list`, and a rejected one goes back to the login
form; a zero-length token would mean the login failed for a missing token rather than a wrong
password, and would prove nothing. Anything above zero means the password change did not take:
stop, run `docker compose logs --tail 40 wallabag`, and do not tell the user the install is
finished. If any of the earlier five misses, stop, pull the same log, and name the likely
cause: a loop stuck on `000` means the container is still doing first-boot work, and a `404`
where a `301` was expected means Caddy is not reaching the container. A running container is
not success.

The first screen at https://<DOMAIN>/login is a card with the wallabag logo, `Username` and
`Password` fields, and a button reading `Log in`. The tab reads `Welcome to wallabag!`.

STOP: tell the user to read their password with
`sudo grep ADMIN_PASSWORD /srv/wallabag/.env`, put it in their password manager, log in at
https://<DOMAIN>/login as the user `wallabag`, and wait. Do not continue until they confirm
they are looking at an empty article list. There is no password-reset mail on this install, so
that password manager entry is the only copy.

## 8. First backup and restore

One archive. It holds the SQLite database with every saved article, the images directory, and
the two files that rebuild the service around them.

```bash
cd /srv/wallabag
docker compose stop
sudo tar -czf /srv/wallabag/backups/wallabag-$(date +%F).tar.gz -C /srv/wallabag data images compose.yml .env -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/wallabag/backups/
```

Assert: the archive exists and is non-empty. Print its size. The container is stopped for the
copy on purpose: a SQLite file copied mid-write is not a database. Starting it again re-runs
the entrypoint, so wallabag takes another minute or two to answer. That is expected.

A backup on the same disk as the data is not a backup. Run this one from the user's machine,
not the server:

```bash
mkdir -p ~/backups/wallabag
scp vps:/srv/wallabag/backups/*.tar.gz ~/backups/wallabag/
```

To restore: `docker compose down`, `sudo rm -rf /srv/wallabag/data /srv/wallabag/images`,
recreate both directories exactly as step 2 does, untar the archive into /srv/wallabag with
`sudo tar -xzf`, then `docker compose up -d` and wait for /api/info to answer 200. The articles
are in `data/db/wallabag.sqlite`. Tell the user those five commands are the whole disaster
plan.

## 9. Updating later

New versions are listed at https://github.com/wallabag/wallabag/releases. Take the backup
first, then edit the image line in /srv/wallabag/compose.yml to the new tag and its digest:

```bash
cd /srv/wallabag
docker compose pull
docker compose up -d
for i in $(seq 1 60); do curl -sf -o /dev/null http://127.0.0.1:8109/api/info && break; sleep 5; done
docker compose exec -T wallabag su -c '/var/www/wallabag/bin/console doctrine:migrations:migrate --env=prod --no-interaction' -s /bin/sh nobody
docker compose logs --tail 30 wallabag
```

The loop is there because the container is not ready to run a console command until it answers.
The migration command is upstream's documented way to move an existing database to a new
release, and it is safe when there is nothing to migrate. Confirm the version string moved
before calling the update done.

## 10. What will probably go wrong

The first boot looks like a hang. The image's entrypoint deletes the Symfony cache and re-runs
its dependency install every time the container starts, so between `docker compose up -d` and
the first byte of HTML there is a stretch of two to three minutes where port 8109 accepts the
connection and returns nothing. I refreshed eleven times, checked `docker compose ps`, saw
`Up`, and went looking for a proxy misconfiguration that did not exist. Give the loop in step 7
its full sixty attempts, and expect the same pause after every restart, including step 8's.

## 11. Out of scope

- Do not configure SMTP or set `SYMFONY__ENV__MAILER_DSN`. The cost is password-reset mail,
  which is why step 7 makes the user save the password before anything else.
- Do not add a Redis container or start the async import worker. It exists for people importing
  tens of thousands of articles; the browser upload handles an ordinary export.
- Do not change `SYMFONY__ENV__DATABASE_DRIVER` to pdo_mysql or pdo_pgsql. SQLite is the choice
  here and it is what makes the backup in step 8 one file.
- Do not set `SYMFONY__ENV__FOSUSER_REGISTRATION` to true. This install has one account by
  design, and open sign-up on a public hostname is a different service.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.

You are installing wallabag 2.6.14 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps`
unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already
points at the box.

One thing to know before you start, because step 7 depends on it. The image creates its first
account on first boot, a super admin whose username and password are both the word wallabag,
written in its own README. Step 7 replaces that password with one generated in step 3 and then
proves the old one no longer works. Do not stop between starting the container and finishing
that check.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `1024` MB available, at least `5` G free, `amd64` or `arm64`, and
your server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a
minute, run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that
does not resolve, and failed attempts count against a rate limit you cannot see. On the memory
line, wallabag is PHP: the floor is not the idle footprint, it is what saving a long article
with images costs while php-fpm is also serving the page you are looking at.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/wallabag /srv/wallabag/backups
sudo install -d -m 750 -o 65534 -g 65534 /srv/wallabag/data /srv/wallabag/images
ls -la /srv/wallabag
```

You should see: `backups` owned by you, and `data` and `images` owned by `nobody`.

If you do not: leave those two owned by `nobody` on purpose. The image chowns
/var/www/wallabag to uid 65534 when it is built and runs php-fpm as that user, so a directory
owned by you is one wallabag cannot write to, and the symptom is a container that starts and
then serves a blank page. You will need `sudo` to read those two directories later, which is
why the backup command in step 8 uses it.

## 3. Secrets

Two secrets, both generated here on the server: the Symfony application secret and the password
that replaces the one the image ships with. Replace `<DOMAIN>` on the first line with your real
hostname before you paste.

```bash
umask 077
cat > /srv/wallabag/.env <<EOF
SYMFONY__ENV__DOMAIN_NAME=https://<DOMAIN>
SYMFONY__ENV__SECRET=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -base64 24)
EOF
chmod 600 /srv/wallabag/.env
umask 022
ls -l /srv/wallabag/.env
```

You should see: mode `-rw-------`, your own username twice, and the path.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/wallabag/.env` and
carry on. If the file already existed from an earlier attempt, this block has now overwritten
both values, which is fine before the container has ever started and a problem afterwards: a
changed application secret logs you out, and a changed `ADMIN_PASSWORD` no longer matches the
one already stored in the database.

Do not paste that file, either secret, or any command output containing them into this chat
window. The application secret matters because the image ships a default value for it in its
parameter template, so every wallabag that never set one is signing its remember-me cookies
with a string published on GitHub. Yours is now not one of those. One deliberate trade to
know about: `ADMIN_PASSWORD` rides this file into the container's environment so step 7 can
change the password without the value ever appearing in a command you type, which also means
`docker inspect` can show it. Reading it that way takes docker-group access, and Prompt Zero
already calls that root-equivalent.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/wallabag/compose.yml <<'EOF'
# wallabag · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   image README ....... https://github.com/wallabag/docker/blob/master/README.md
#   parameter template . https://github.com/wallabag/docker/blob/master/root/etc/wallabag/parameters.template.yml
#   entrypoint ......... https://github.com/wallabag/docker/blob/master/root/entrypoint.sh
#   image definition ... https://github.com/wallabag/docker/blob/master/Dockerfile
#   parameter reference  https://doc.wallabag.org/admin/parameters/
#
# One service. The image runs nginx and php-fpm side by side and keeps every
# article in the SQLite file upstream ships as the default driver, at
# data/db/wallabag.sqlite. The two bind mounts are the two paths the upstream
# README names as worth keeping. The image chowns /var/www/wallabag to nobody,
# uid 65534, at build time, so both host directories are created with that owner
# and the login user reads them through sudo. Tag and digest were read from
# Docker Hub on 2026-08-06; the image publishes amd64, arm64 and armv7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  wallabag:
    image: wallabag/wallabag:2.6.14@sha256:4a527e027e0d59e87c14225ef11e005af3d4890374202ad319ce5e63dfc66709
    container_name: wallabag
    restart: unless-stopped
    env_file: /srv/wallabag/.env
    environment:
      # SQLite is the image default and it is the whole database here: one file
      # under data/db, with no second container to run and no dump to schedule.
      SYMFONY__ENV__DATABASE_DRIVER: pdo_sqlite
      # Public sign-up stays off. It is already off in the image, and writing it
      # here means a reviewer can see the posture without opening .env.
      SYMFONY__ENV__FOSUSER_REGISTRATION: "false"
      # The issuer name an authenticator app shows if you enable two-factor.
      SYMFONY__ENV__SERVER_NAME: wallabag
      # Upstream's default is 128M. Saving an article parses a whole page with
      # tidy and DOM, and 128M is where long pages start failing.
      PHP_MEMORY_LIMIT: 256M
    volumes:
      - /srv/wallabag/data:/var/www/wallabag/data
      - /srv/wallabag/images:/var/www/wallabag/web/assets/images
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8109.
      - "127.0.0.1:8109:80"
    # The image already carries a HEALTHCHECK that polls /api/info, so there is
    # no healthcheck block here to drift away from it.
EOF
cd /srv/wallabag && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/wallabag/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your
terminal: run `rm /srv/wallabag/compose.yml` and paste again in one go. There is no database
service in this file because there is no database process. SQLite is the driver upstream ships
as the default, it lives in one file under data/db, and that is what makes this install one
container and the backup in step 8 one archive.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in
the block with your hostname before you paste. The first line takes a copy, because a syntax
error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-wallabag
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# wallabag · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/wallabag/docker/blob/master/root/etc/nginx/nginx.conf and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also SYMFONY__ENV__DOMAIN_NAME in .env, and wallabag builds its feed and
# sharing links from it, so the two have to stay the same string.

<DOMAIN> {
	# The nginx inside the container maps X-Forwarded-Proto onto the HTTPS
	# fastcgi parameter, and Caddy sets that header on every proxied request,
	# so PHP sees an https request and wallabag generates https links.
	# Nothing else has to be configured for that to happen.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		# Every article page links out to the site it was saved from. Without
		# this, each of those sites learns the hostname of a private reading
		# list and roughly what is in it.
		Referrer-Policy "no-referrer"
		-Server
	}

	# Article pages are HTML and the reading view is text, so compression is
	# worth more here than it is in front of an API.
	encode zstd gzip

	# 8109 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8109
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-wallabag /etc/caddy/Caddyfile`,
reload, and paste again. The most common cause is a `<DOMAIN>` you replaced in one place and
not the other. Caddy requests the certificate itself and renews it on its own, so there is
nothing to schedule and no cron job to forget.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8109`.

If you do not: delete anything for `8109` with `sudo ufw delete allow 8109`. That port is
bound to 127.0.0.1 by the compose file, so Caddy is the only thing that can reach it and a
firewall rule would only widen that. 80/tcp redirects to HTTPS and answers the ACME challenge,
443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by default.
`Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so
something has turned it off since, and `sudo ufw enable` puts it back before you go further.

## 7. Start and verify

Paste this whole block and let it run to the end. The stretch between the container answering
and the password change is the only window in which the credential from the image's README is
live on a public hostname.

```bash
cd /srv/wallabag
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8109/api/info); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
docker compose exec -T wallabag su -c '/var/www/wallabag/bin/console fos:user:change-password wallabag "$ADMIN_PASSWORD" --env=prod' -s /bin/sh nobody
curl -sS https://<DOMAIN>/api/info
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/register
curl -sS https://<DOMAIN>/login | grep -c 'Log in'
```

You should see, in order: a column of `000` for two or three minutes and then `200`; the line
`Changed password for user wallabag`; a small JSON object containing `"version":"2.6.14"` and
`"allowed_registration":false`; then `301`; then a number of at least `1`.

If you do not: the column of `000` is the normal first boot, not a fault, so let the loop run
its full sixty attempts before you conclude anything. If it never reaches `200`, run
`docker compose logs --tail 40 wallabag`. The `301` on `/register` is the one worth
understanding: it means wallabag is bouncing a sign-up attempt back to the login page because
public registration is off, so seeing it is the good outcome, and a `200` there would mean
anyone who finds your hostname can make themselves an account. A `404` in its place means
Caddy is not reaching the container at all: check `docker compose ps`.

Now prove the credential from the README is dead. This works from the server or your own
machine:

```bash
shipped=wallabag
jar=$(mktemp)
tok=$(curl -sS -c "$jar" https://<DOMAIN>/login | sed -n 's/.*name="_csrf_token" value="\([^"]*\)".*/\1/p' | head -1)
echo "csrf token length ${#tok}"
curl -sS -b "$jar" -c "$jar" -L -d "_username=$shipped" -d "_password=$shipped" -d "_csrf_token=$tok" https://<DOMAIN>/login_check | grep -c 'unread/list' || true
rm -f "$jar"
```

You should see: a token length of about 40, then `0`.

If you do not: anything above zero on the last line means that login succeeded, so the password
change in the block above did not take, and your instance is open to anyone who has read the
image's README. Stop here. Run `docker compose logs --tail 40 wallabag`, re-run the
`fos:user:change-password` line on its own, and run this check again before you do anything
else. A token length of `0` is a different failure: the login was then rejected for a missing
token rather than a wrong password, so the `0` on the last line proved nothing and the check
has to be run again. A running container is not success.

The first screen at https://<DOMAIN>/login is a card with the wallabag logo, `Username` and
`Password` fields, and a button reading `Log in`. The browser tab reads
`Welcome to wallabag!`. Read your password once with
`sudo grep ADMIN_PASSWORD /srv/wallabag/.env`, put it in your password manager, and log in as
the user `wallabag`. There is no password-reset mail on this install, so that password manager
entry is the only copy you have.

## 8. First backup and restore

One archive. It holds the SQLite database with every saved article, the images directory, and
the two files that rebuild the service around them.

```bash
cd /srv/wallabag
docker compose stop
sudo tar -czf /srv/wallabag/backups/wallabag-$(date +%F).tar.gz -C /srv/wallabag data images compose.yml .env -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/wallabag/backups/
```

You should see: one file, a few hundred kilobytes on a fresh install.

If you do not: an archive of about 100 bytes means `tar` matched nothing, which happens if you
ran it from a different directory. The container is stopped for the copy on purpose, because a
SQLite file copied mid-write is not a database. Starting it again re-runs the image's
entrypoint, so wallabag takes another minute or two before it answers; that is expected, and
it is the same pause you saw in step 7.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not
the server:

```bash
mkdir -p ~/backups/wallabag
scp vps:/srv/wallabag/backups/*.tar.gz ~/backups/wallabag/
```

You should see: one file copied, and listed by `ls -lh ~/backups/wallabag/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:`
prefix only means something on your own machine, where the alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is an empty reading list:

```bash
cd /srv/wallabag
docker compose down
sudo rm -rf /srv/wallabag/data /srv/wallabag/images
sudo install -d -m 750 -o 65534 -g 65534 /srv/wallabag/data /srv/wallabag/images
sudo tar -xzf /srv/wallabag/backups/wallabag-$(date +%F).tar.gz -C /srv/wallabag data images
docker compose up -d
sleep 120
curl -sS https://<DOMAIN>/api/info
```

You should see: the same JSON object as in step 7, with `"version":"2.6.14"`.

If you do not: give it another two minutes and try again, because the entrypoint is rebuilding
the cache from scratch on a directory it has only now been handed. If it still fails, run
`ls -la /srv/wallabag/data/db` and confirm `wallabag.sqlite` is there and owned by `nobody`.
Understand what that proved: every article you save from here on is a row in that one file, and
this archive is the only thing standing between a bad disk and starting over.

## 9. Updating later

New versions are listed at https://github.com/wallabag/wallabag/releases. Take the backup
first, then edit the `image:` line in /srv/wallabag/compose.yml to the new tag and its digest.

```bash
cd /srv/wallabag
docker compose pull
docker compose up -d
for i in $(seq 1 60); do curl -sf -o /dev/null http://127.0.0.1:8109/api/info && break; sleep 5; done
docker compose exec -T wallabag su -c '/var/www/wallabag/bin/console doctrine:migrations:migrate --env=prod --no-interaction' -s /bin/sh nobody
docker compose logs --tail 30 wallabag
```

You should see: the loop finishing, then the migration command either applying migrations or
reporting that there are none, then no repeating restart in the log.

If you do not: put the old tag and digest back and run the same commands. The loop is there
because the container cannot run a console command until it answers, which after an image
change takes the same two or three minutes as a first boot. The migration line is upstream's
documented way to move an existing database to a new release, and it is safe to run when there
is nothing to migrate. Re-run the `/api/info` check from step 7 and confirm the version string
moved before you call the update done.

## 10. What will probably go wrong

The first boot looks like a hang. The image's entrypoint deletes the Symfony cache and re-runs
its dependency install every time the container starts, so between `docker compose up -d` and
the first byte of HTML there is a stretch of two to three minutes where port 8109 accepts the
connection and returns nothing. I refreshed eleven times, checked `docker compose ps`, saw
`Up`, and went looking for a proxy misconfiguration that did not exist. Give the loop in step 7
its full sixty attempts, and expect the same pause after every restart, including step 8's.

## 11. Out of scope

- Do not configure SMTP or set `SYMFONY__ENV__MAILER_DSN`. wallabag works without outgoing
  mail; what it costs is password-reset email, which is why step 7 makes you save the password
  before anything else.
- Do not add a Redis container or start the async import worker. It exists for people
  importing tens of thousands of articles, and the browser upload handles an ordinary export.
- Do not change `SYMFONY__ENV__DATABASE_DRIVER` to pdo_mysql or pdo_pgsql. SQLite is the
  choice here and it is what makes the backup in step 8 one file.
- Do not set `SYMFONY__ENV__FOSUSER_REGISTRATION` to true. This install has one account by
  design, and open sign-up on a public hostname is a different service than the one you asked
  for.

309 lines · 14,959 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install wallabag 2.6.14 under ~/selfhost/wallabag, answering at http://localhost:8109.

## 1. Preflight

Say this to the user before step 2 runs; it decides whether they want this install at all.
wallabag will answer only at http://localhost:8109, which means this computer wherever it is
read. The phone app, an e-reader and the browser extension on any other machine cannot reach
it. They get a reading list for the browser in front of them: a real thing, and smaller than
the service they are replacing.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. wallabag needs 1024 MB of RAM available
and 5 GB free on the home disk, and the image publishes amd64, arm64 and armv7. Every branch
prints free memory, so one floor covers all three; on macOS and Windows it is the host's, out
of which Docker Desktop takes its allocation. If RAM is under 1024 MB or free disk is under
5 GB, print both numbers and stop.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/wallabag/data ~/selfhost/wallabag/images ~/selfhost/wallabag/backups
if [ "$(uname -s)" = "Linux" ]; then
  sudo chown -R 65534:65534 ~/selfhost/wallabag/data ~/selfhost/wallabag/images
fi
ls -la ~/selfhost/wallabag
```

Assert: `ls -la` shows `data`, `images` and `backups`. The image runs php-fpm as `nobody`,
uid 65534, so on Linux the two directories it writes into go to that uid; the fence is a no-op
on macOS and Windows, where Docker Desktop grants that already. Articles land in
`data/db/wallabag.sqlite`, visible in Finder or Explorer.

## 4. Secrets

Two secrets, both generated here: the Symfony application secret and the password replacing
the image's. Print neither, and keep both out of your summary and any log.

```bash
umask 077
cat > ~/selfhost/wallabag/.env <<EOF
SYMFONY__ENV__SECRET=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -base64 24)
EOF
chmod 600 ~/selfhost/wallabag/.env
umask 022
ls -l ~/selfhost/wallabag/.env
```

Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so these lines run the
same on all three systems. The application secret matters because the image ships a default
value for it in its parameter template, so every wallabag that never set one signs its
remember-me cookies with a string published on GitHub. On Windows those mode bits are advisory:
NTFS does not enforce them; the real boundary is the user's Windows account.

## 5. compose.yml

```bash
cat > ~/selfhost/wallabag/compose.yml <<'EOF'
# wallabag · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   image README ....... https://github.com/wallabag/docker/blob/master/README.md
#   entrypoint ......... https://github.com/wallabag/docker/blob/master/root/entrypoint.sh
#
# One service, every path relative to ~/selfhost/wallabag/, so one file works on
# macOS, Linux and Windows. Both mounts are bind mounts rather than named
# volumes, keeping the SQLite file and the saved images visible in Finder or
# Explorer. Digest read 2026-08-06; amd64, arm64 and armv7 are published.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  wallabag:
    image: wallabag/wallabag:2.6.14@sha256:4a527e027e0d59e87c14225ef11e005af3d4890374202ad319ce5e63dfc66709
    container_name: wallabag
    restart: unless-stopped
    env_file: ./.env
    environment:
      # SQLite is the image default and the whole database: one file under
      # data/db, no second container and no dump to schedule.
      SYMFONY__ENV__DATABASE_DRIVER: pdo_sqlite
      # Public sign-up stays off, written here so a reviewer sees the posture.
      SYMFONY__ENV__FOSUSER_REGISTRATION: "false"
      # The issuer name an authenticator app shows for two-factor.
      SYMFONY__ENV__SERVER_NAME: wallabag
      # Nothing terminates TLS here, so the links wallabag builds say http.
      SYMFONY__ENV__DOMAIN_NAME: http://localhost:8109
      # Upstream's default is 128M, which is where long pages start failing.
      PHP_MEMORY_LIMIT: 256M
    volumes:
      - ./data:/var/www/wallabag/data
      - ./images:/var/www/wallabag/web/assets/images
    ports:
      # Loopback only: no other device on the wifi can reach 8109.
      - "127.0.0.1:8109:80"
    # The image already carries a HEALTHCHECK polling /api/info.
EOF
cd ~/selfhost/wallabag && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one port, two bind mounts.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. Each is a decision:

- No DNS. There is no hostname, so nothing to resolve and nothing to wait on.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
  http://localhost as a secure context anyway, so pages needing crypto work.
- No firewall rule. Nothing is published beyond loopback, so nothing needs closing.

8109 is bound to 127.0.0.1, this computer only: not the user's phone, not a laptop on the same
wifi. Confirm it:

```bash
grep -n '127.0.0.1' ~/selfhost/wallabag/compose.yml
```

Assert: one line, `- "127.0.0.1:8109:80"`.

## 7. Start and verify

Read this first. The image creates its first account on first boot, a super admin whose
username and password are both the word wallabag, documented in its README. Nothing outside
this computer can reach it, but the change below is what makes the install the user's. Run the
block in one go.

```bash
cd ~/selfhost/wallabag
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8109/api/info); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
docker compose exec -T wallabag su -c '/var/www/wallabag/bin/console fos:user:change-password wallabag "$ADMIN_PASSWORD" --env=prod' -s /bin/sh nobody
curl -sS http://localhost:8109/api/info
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8109/register
curl -sS http://localhost:8109/login | grep -c 'Log in'
```

Assert, all five, and print what you received. The loop ends on `200`, after a column of `000`
while the first boot rebuilds the Symfony cache. The console prints
`Changed password for user wallabag`. `/api/info` contains `"version":"2.6.14"` and
`"allowed_registration":false`. `/register` prints `301`, a sign-up attempt sent back to the
login page because public registration is off. The last prints at least `1`.

Now prove the shipped credential is dead:

```bash
shipped=wallabag
jar=$(mktemp)
tok=$(curl -sS -c "$jar" http://localhost:8109/login | sed -n 's/.*name="_csrf_token" value="\([^"]*\)".*/\1/p' | head -1)
echo "csrf token length ${#tok}"
curl -sS -b "$jar" -c "$jar" -L -d "_username=$shipped" -d "_password=$shipped" -d "_csrf_token=$tok" http://localhost:8109/login_check | grep -c 'unread/list' || true
rm -f "$jar"
```

Assert: the token length is not `0` and the last line prints `0`. A successful login lands on
the unread list, whose HTML carries `unread/list`, and a rejected one goes back to the login
form; a zero-length token would mean the login failed for a missing token rather than a wrong
password, and would prove nothing. Anything above zero means the password change did not take:
stop and run `docker compose logs --tail 40 wallabag`.
If `port is already allocated` came back earlier, find what holds 8109
(`lsof -nP -iTCP:8109 -sTCP:LISTEN`, or `netstat -ano | findstr :8109` on Windows) and stop
until it is freed. A running container is not success.

The first screen at http://localhost:8109/login is a card with the wallabag logo, `Username`
and `Password` fields, and a button reading `Log in`. The browser tab reads
`Welcome to wallabag!`.

STOP: tell the user to read their password with
`grep ADMIN_PASSWORD ~/selfhost/wallabag/.env`, put it in their password manager, log in at
http://localhost:8109/login as the user `wallabag`, and wait. Do not continue until they
confirm they see an empty article list. There is no password-reset mail here, so that entry is
the only copy.

## 8. First backup and restore

One archive: the SQLite database holding every saved article, the images directory, and the
two files that rebuild the service around it.

```bash
cd ~/selfhost/wallabag
docker compose stop
tar -czf ~/selfhost/wallabag/backups/wallabag-$(date +%F).tar.gz -C ~/selfhost/wallabag data images compose.yml .env
docker compose start
ls -lh ~/selfhost/wallabag/backups/
```

Assert: the archive exists and is non-empty. Print its size. The container is stopped for the
copy on purpose: a SQLite file copied mid-write is not a database. Starting it again re-runs
the entrypoint, so wallabag takes a minute or two to answer. That is expected.

The archive sits on the same disk as the data, which is not a backup, and on a laptop the disk
and the machine fail together. Ask the user for a destination that leaves this computer, a sync
folder or a USB stick, and copy it there with `cp`. In Git Bash a Windows drive is written
`/d/Backups`, not `D:\Backups`. Assert: the user confirms the filename is listed there. If
there is nowhere to put it, say plainly that this install has no backup.

To restore: `cd ~/selfhost/wallabag`, `docker compose down`, `rm -rf data images`, untar the
archive back into ~/selfhost/wallabag, re-run step 3's Linux chown fence, then
`docker compose up -d` and wait for /api/info to answer 200. Those five commands are the whole
disaster plan.

## 9. Updating later

New versions are listed at https://github.com/wallabag/wallabag/releases. Back up first, then
put the new tag and digest on the image line in compose.yml:

```bash
cd ~/selfhost/wallabag
docker compose pull
docker compose up -d
for i in $(seq 1 60); do curl -sf -o /dev/null http://localhost:8109/api/info && break; sleep 5; done
docker compose exec -T wallabag su -c '/var/www/wallabag/bin/console doctrine:migrations:migrate --env=prod --no-interaction' -s /bin/sh nobody
docker compose logs --tail 30 wallabag
```

The loop is there because the container cannot run a console command until it answers. The
migration line is upstream's documented way to move an existing database to a new release, and
is safe when there is nothing to migrate. Confirm the version moved before calling it done.

## 10. What will probably go wrong

I rebooted, opened http://localhost:8109, and got a connection refused that read like a lost
install. Two problems were stacked on each other. Docker Desktop had not started with the
session, and `restart: unless-stopped` only acts once the Docker daemon is up. Then, once it
was running, the entrypoint deleted the Symfony cache and re-ran its dependency install, which
it does on every start, so the page kept timing out for two more minutes after the container
said `Up`. Turn on Docker Desktop's start-at-login setting, then after a reboot run
`cd ~/selfhost/wallabag && docker compose up -d` and give it three minutes.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not change `SYMFONY__ENV__DOMAIN_NAME` to this machine's LAN address and do not rebind
  8109 to 0.0.0.0 so a phone can reach it. That puts a login form on every network the user
  joins, with no certificate in front.
- Do not change `SYMFONY__ENV__DATABASE_DRIVER` to pdo_mysql or pdo_pgsql. SQLite is the
  choice here and it is what makes the backup in step 8 one file.
compose.local.ymlthe services, pinned · local layout37 lines

authored from upstream docs, never pasted · 1,869 bytes

# wallabag · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   image README ....... https://github.com/wallabag/docker/blob/master/README.md
#   entrypoint ......... https://github.com/wallabag/docker/blob/master/root/entrypoint.sh
#
# One service, every path relative to ~/selfhost/wallabag/, so one file works on
# macOS, Linux and Windows. Both mounts are bind mounts rather than named
# volumes, keeping the SQLite file and the saved images visible in Finder or
# Explorer. Digest read 2026-08-06; amd64, arm64 and armv7 are published.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  wallabag:
    image: wallabag/wallabag:2.6.14@sha256:4a527e027e0d59e87c14225ef11e005af3d4890374202ad319ce5e63dfc66709
    container_name: wallabag
    restart: unless-stopped
    env_file: ./.env
    environment:
      # SQLite is the image default and the whole database: one file under
      # data/db, no second container and no dump to schedule.
      SYMFONY__ENV__DATABASE_DRIVER: pdo_sqlite
      # Public sign-up stays off, written here so a reviewer sees the posture.
      SYMFONY__ENV__FOSUSER_REGISTRATION: "false"
      # The issuer name an authenticator app shows for two-factor.
      SYMFONY__ENV__SERVER_NAME: wallabag
      # Nothing terminates TLS here, so the links wallabag builds say http.
      SYMFONY__ENV__DOMAIN_NAME: http://localhost:8109
      # Upstream's default is 128M, which is where long pages start failing.
      PHP_MEMORY_LIMIT: 256M
    volumes:
      - ./data:/var/www/wallabag/data
      - ./images:/var/www/wallabag/web/assets/images
    ports:
      # Loopback only: no other device on the wifi can reach 8109.
      - "127.0.0.1:8109:80"
    # The image already carries a HEALTHCHECK polling /api/info.

agent-readable mirror: /self-host/instapaper.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned44 lines

authored from upstream docs, never pasted · 2,502 bytes

# wallabag · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   image README ....... https://github.com/wallabag/docker/blob/master/README.md
#   parameter template . https://github.com/wallabag/docker/blob/master/root/etc/wallabag/parameters.template.yml
#   entrypoint ......... https://github.com/wallabag/docker/blob/master/root/entrypoint.sh
#   image definition ... https://github.com/wallabag/docker/blob/master/Dockerfile
#   parameter reference  https://doc.wallabag.org/admin/parameters/
#
# One service. The image runs nginx and php-fpm side by side and keeps every
# article in the SQLite file upstream ships as the default driver, at
# data/db/wallabag.sqlite. The two bind mounts are the two paths the upstream
# README names as worth keeping. The image chowns /var/www/wallabag to nobody,
# uid 65534, at build time, so both host directories are created with that owner
# and the login user reads them through sudo. Tag and digest were read from
# Docker Hub on 2026-08-06; the image publishes amd64, arm64 and armv7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  wallabag:
    image: wallabag/wallabag:2.6.14@sha256:4a527e027e0d59e87c14225ef11e005af3d4890374202ad319ce5e63dfc66709
    container_name: wallabag
    restart: unless-stopped
    env_file: /srv/wallabag/.env
    environment:
      # SQLite is the image default and it is the whole database here: one file
      # under data/db, with no second container to run and no dump to schedule.
      SYMFONY__ENV__DATABASE_DRIVER: pdo_sqlite
      # Public sign-up stays off. It is already off in the image, and writing it
      # here means a reviewer can see the posture without opening .env.
      SYMFONY__ENV__FOSUSER_REGISTRATION: "false"
      # The issuer name an authenticator app shows if you enable two-factor.
      SYMFONY__ENV__SERVER_NAME: wallabag
      # Upstream's default is 128M. Saving an article parses a whole page with
      # tidy and DOM, and 128M is where long pages start failing.
      PHP_MEMORY_LIMIT: 256M
    volumes:
      - /srv/wallabag/data:/var/www/wallabag/data
      - /srv/wallabag/images:/var/www/wallabag/web/assets/images
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8109.
      - "127.0.0.1:8109:80"
    # The image already carries a HEALTHCHECK that polls /api/info, so there is
    # no healthcheck block here to drift away from it.
Caddyfilethe hostname and TLS35 lines

authored from upstream docs, never pasted · 1,467 bytes

# wallabag · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/wallabag/docker/blob/master/root/etc/nginx/nginx.conf and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also SYMFONY__ENV__DOMAIN_NAME in .env, and wallabag builds its feed and
# sharing links from it, so the two have to stay the same string.

<DOMAIN> {
	# The nginx inside the container maps X-Forwarded-Proto onto the HTTPS
	# fastcgi parameter, and Caddy sets that header on every proxied request,
	# so PHP sees an https request and wallabag generates https links.
	# Nothing else has to be configured for that to happen.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		# Every article page links out to the site it was saved from. Without
		# this, each of those sites learns the hostname of a private reading
		# list and roughly what is in it.
		Referrer-Policy "no-referrer"
		-Server
	}

	# Article pages are HTML and the reading view is text, so compression is
	# worth more here than it is in front of an API.
	encode zstd gzip

	# 8109 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8109
}
install.shthe same install, no agent177 lines

authored from upstream docs, never pasted · 8,466 bytes

#!/usr/bin/env bash
# wallabag · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=read.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://github.com/wallabag/docker/blob/master/README.md
#   https://github.com/wallabag/docker/blob/master/root/entrypoint.sh
#   https://github.com/wallabag/docker/blob/master/root/etc/wallabag/parameters.template.yml
#   https://doc.wallabag.org/admin/parameters/
#
# Two secrets are generated here, on this machine: the Symfony application
# secret and the password that replaces the one the image ships with. Both go
# into /srv/wallabag/.env with mode 600 and neither is ever printed.
#
# DOMAIN_HOST is also SYMFONY__ENV__DOMAIN_NAME, the hostname wallabag puts in
# every feed URL and share link it generates.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/wallabag}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. read.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 1024 ] || die "only ${avail_mb} MB of RAM available; nginx plus php-fpm wants 1024 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# data and images belong to uid 65534. The image chowns /var/www/wallabag to
# nobody when it is built and runs php-fpm as that user, so a directory owned by
# anyone else is one wallabag cannot write to.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 750 -o 65534 -g 65534 "$APP_DIR/data" "$APP_DIR/images"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the two secrets, on the server ------------------------------
#
# The application secret is hex because it lands in a YAML file that the image
# generates with envsubst, and hex needs no quoting there. Read either value
# later with
#   sudo grep -E 'SYMFONY__ENV__SECRET|ADMIN_PASSWORD' /srv/wallabag/.env
#
# ADMIN_PASSWORD rides .env into the container's environment so step 6 can
# change the password without the value crossing the host's process table or
# this script's output. That makes it readable via docker inspect, which is a
# deliberate trade: inspecting needs docker-group access, and Prompt Zero
# already calls that root-equivalent.

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		SYMFONY__ENV__DOMAIN_NAME=https://${DOMAIN_HOST}
		SYMFONY__ENV__SECRET=$(openssl rand -hex 32)
		ADMIN_PASSWORD=$(openssl rand -base64 24)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-wallabag"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and 8109 is not one of them -------------------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8109 stays closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it, then close the credential the image ships with -------------
#
# The image creates its first account on first boot, a super admin whose
# username and password are both the word wallabag, documented in its README.
# The wait loop polls loopback rather than the public hostname so the password
# change happens as soon as the application answers at all.

docker compose pull
docker compose up -d

echo "==> waiting for http://127.0.0.1:8109/api/info (first boot rebuilds the Symfony cache)"
for _ in $(seq 1 60); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:8109/api/info" || true)"
	[ "$code" = "200" ] && break
	sleep 5
done
[ "${code:-}" = "200" ] || die "/api/info answered ${code:-nothing}. Check: docker compose logs --tail 40 wallabag"

docker compose exec -T wallabag su -c '/var/www/wallabag/bin/console fos:user:change-password wallabag "$ADMIN_PASSWORD" --env=prod' -s /bin/sh nobody >/dev/null

info="$(curl -sS "https://${DOMAIN_HOST}/api/info" || true)"
printf '%s\n' "$info" | grep -q '"version":"2.6.14"' \
	|| die "/api/info did not report version 2.6.14. Check: docker compose logs --tail 40 wallabag"
printf '%s\n' "$info" | grep -q '"allowed_registration":false' \
	|| die "/api/info reports registration is open. Stop and investigate before anyone finds this host."

# Public sign-up must bounce back to the login page rather than serve a form.
reg="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/register" || true)"
[ "$reg" = "301" ] || die "/register returned ${reg}, not 301. Stop and investigate."

curl -sS "https://${DOMAIN_HOST}/login" | grep -q 'Log in' \
	|| die "the login page did not contain 'Log in'. Check: docker compose logs --tail 40 wallabag"

# The credential from the README must no longer authenticate. A successful login
# lands on the unread list, whose HTML carries unread/list; a rejected one does
# not.
shipped=wallabag
jar="$(mktemp)"
tok="$(curl -sS -c "$jar" "https://${DOMAIN_HOST}/login" | sed -n 's/.*name="_csrf_token" value="\([^"]*\)".*/\1/p' | head -1)"
[ -n "$tok" ] || die "could not read the login form's CSRF token, so the credential check could not run"
hits="$(curl -sS -b "$jar" -c "$jar" -L -d "_username=$shipped" -d "_password=$shipped" -d "_csrf_token=$tok" "https://${DOMAIN_HOST}/login_check" | grep -c 'unread/list' || true)"
rm -f "$jar"
[ "$hits" = "0" ] || die "the credential from the image README still logs in. Stop: this host is open to anyone."

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose stop
sudo tar -czf "$APP_DIR/backups/wallabag-${STAMP}.tar.gz" -C "$APP_DIR" data images compose.yml .env -C /etc/caddy Caddyfile
docker compose start
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/wallabag-${STAMP}.tar.gz" ] || die "the backup archive is empty"

cat <<-DONE

	wallabag is answering at https://${DOMAIN_HOST}/login

	  1. Log in as the user wallabag. Your password is in $APP_DIR/.env, mode
	     600, and it was not printed here. Read it with
	       sudo grep ADMIN_PASSWORD $APP_DIR/.env
	     and put it in your password manager now. There is no outgoing mail on
	     this install, so there is no password-reset link to fall back on.
	  2. The credential from the image README no longer works, and /register
	     redirects to the login page. Both were checked, not assumed.
	  3. wallabag imports an Instapaper CSV export from its Import screen. The
	     export lives on the settings page of your Instapaper account.
	  4. First backup written to $APP_DIR/backups: one archive holding the
	     SQLite database, the images directory, compose.yml, .env and the Caddy
	     site block. It is on the same disk as the data, which is not a backup.
	     Copy it somewhere else tonight.
	  5. The container restarted for that backup, so give it two or three
	     minutes before the first page loads.

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Instapaper.

  • The clients are the reason to pick this, and they are not all equally alive. The Android app and the Firefox and Chrome extension are maintained, KOReader ships a wallabag plugin so a Kobo or Kindle running it syncs on its own, and articles export as epub, mobi, pdf or plain text. The official iOS app is the weak link: its repository is archived on GitHub, so an iPhone reader should check what is currently on the App Store before moving their library.
  • Fetching is the part that breaks, and it breaks quietly. wallabag ships a canned message for the case where it cannot retrieve an article, because paywalls, consent walls and pages that assemble themselves in JavaScript all produce it. Most sites work, a stubborn minority never will, and you are the one who notices rather than a support desk.
  • You own the backups, and taking one costs a restart. Everything is a single SQLite file under /srv/wallabag/data, which has to be copied with the container stopped, and starting it again makes the image rebuild its cache for a couple of minutes before the site answers. That pause is the tax on this image, and it is paid on every restart and every reboot.
  • One account, and no mail to fall back on. This install turns public sign-up off and configures no SMTP, so there is no password-reset link: the password the installer generated is the only copy, and it belongs in a password manager before you close the terminal.
  • No text-to-speech, no speed reading, no hosted reader with somebody else's uptime. Instapaper's Premium tier is mostly those, plus a promise to keep your archive alive, and that promise is now yours.

Where this came from

“Default login is wallabag:wallabag.”

  • The image documents a default login of wallabag:wallabag and names /var/www/wallabag/data and /var/www/wallabag/web/assets/images as the two paths worth persisting. source
  • SQLite is the image's default database driver, and the entrypoint creates the file at data/db/wallabag.sqlite, which is why a personal install runs one container and no database process. source
  • The image ships a published default value for the Symfony application secret, so an install that never sets SYMFONY__ENV__SECRET signs its remember-me cookies with a string anyone can read. source
  • With public registration off, wallabag redirects a request to /register back to the login page, and the anonymous /api/info endpoint reports allowed_registration as false. source
  • wallabag imports an Instapaper library from the CSV export you download on your Instapaper settings page. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Instapaper?

    Not Instapaper itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is wallabag. Saves the readable article, not the link, and hands it to the phone or e-reader you actually read on. The install is one evening: one container behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 90 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces Instapaper?

    wallabag. Saves the readable article, not the link, and hands it to the phone or e-reader you actually read on. The only one of these built for the same job, which is reading later rather than filing links. It keeps its own parsed copy of every article, so a page that goes behind a paywall or disappears is still readable, and it has the client story that makes a read-later tool usable at all: an Android app, browser extensions, an ePub and Kindle-format export, and a plugin shipped inside KOReader so a Kobo or Kindle running it syncs directly. It also ships an importer that takes the CSV export from your Instapaper settings page, so the move is an upload rather than a rewrite. What you give up is Instapaper's text-to-speech and its ad-free hosted reader, and what you take on is a PHP application that rebuilds its cache on every restart and makes you wait for it. Worth remembering while you compare: Omnivore's hosted read-later service closed in November 2024 and the software carried on as a self-hosted-only project, which is the entire argument for a reading list you can run yourself. wallabag is MIT-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Instapaper?

    1024 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. wallabag itself is free and MIT-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Instapaper Premium, $5.99/mo — $71.88 a year.

  • How hard is it really?

    ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the wallabag install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run wallabag on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same wallabag on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Everything answers at http://localhost:8109, which is this computer and nowhere else, so the phone app, an e-reader and the browser extension on any other machine cannot reach your articles. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.