2026-07-02 22:24:01 +02:00
2026-06-12 19:22:07 +02:00
2026-06-12 19:34:08 +02:00
2026-07-02 22:24:01 +02:00
2026-06-07 15:43:43 +02:00
2026-06-07 15:43:43 +02:00
2026-06-19 13:44:05 +02:00
2022-12-24 16:34:17 +01:00
2026-06-07 16:50:14 +02:00
2026-06-14 09:03:06 +02:00
2026-06-14 09:03:06 +02:00

RSS Reader

A self-hosted RSS reader: a Rust/actix-web + Diesel/PostgreSQL backend with a Vue 3 single-page-app frontend. Sync feeds, read articles, and mark them as read — from your desktop or your phone.

Stack

  • Backend: Rust, actix-web, Diesel ORM, PostgreSQL, JWT auth
  • Frontend: Vue 3, Vite, axios
  • Database: PostgreSQL 18

Development setup

Prerequisites

  • Rust (stable toolchain)
  • Node.js 20+ and npm
  • PostgreSQL (locally, or via Docker — see below)
  • The Diesel CLI: cargo install diesel_cli --no-default-features --features postgres (requires libpq; on Debian/Ubuntu: sudo apt install libpq-dev)

1. Configure environment variables

Copy the example file and fill in your own values:

cp .env.example .env

.env is read by both the backend (via dotenv) and the diesel CLI, and is gitignored — never commit it.

Variable Purpose
DATABASE_URL Postgres connection string, e.g. postgres://admin:changeme@localhost/rss
JWT_SECRET Secret used to sign/verify auth tokens — use a long random string
FRONTEND_ORIGIN Origin allowed by CORS, e.g. http://localhost:5173 for the Vite dev server
RUST_LOG Log level for env_logger, e.g. info
POSTGRES_PASSWORD Password for the admin Postgres user (used by docker-compose.yml)

2. Start PostgreSQL

The simplest way during development is to run just the database container:

docker compose up postgres -d

This starts Postgres on localhost:5432 with the credentials from .env. Alternatively, point DATABASE_URL at any Postgres instance you already have running.

3. Run database migrations

Migrations are embedded in the backend binary and also run automatically on startup, but you can apply them manually with the Diesel CLI (useful when generating new migrations during development):

diesel setup            # creates the database and runs existing migrations
diesel migration run    # applies any pending migrations
diesel migration generate <name>   # scaffolds a new up.sql/down.sql pair

4. Run the backend

cargo run

The API listens on http://0.0.0.0:8001. Backend logs respect RUST_LOG.

5. Run the frontend

cd vue
npm install
npm run dev -- --host

The Vite dev server runs on http://localhost:5173 (the --host flag also exposes it on your LAN so you can test from a phone) and proxies /api requests to http://localhost:8001 (configured in vue/vite.config.js).

6. Try it out

Create a user, then log in through the UI at http://localhost:5173:

curl -X POST -H "Content-Type: application/json" \
  -d '{"name": "mace", "email": "you@example.com", "password": "secret1"}' \
  http://localhost:8001/api/v1/user/create

Passwords must be at least 6 characters.

Useful commands during development

# Backend
cargo build              # compile
cargo test               # run the test suite (needs a reachable Postgres + DATABASE_URL)
cargo clippy             # lint
cargo fmt                # format
# Frontend (run from vue/)
npm run test             # run Vitest component tests
npm run lint             # eslint
npm run format           # prettier
# Inspect the dockerized database directly
docker exec -it rss-postgres psql -d rss -U admin

Security notes

  • Sessions: login returns a JWT (token header) valid for 24 hours. Logging out (POST /api/v1/auth/logout, requires the current token) bumps the user's token_version, which immediately invalidates all outstanding tokens for that account — there's no per-session revocation, so logging out on one device logs out every device.
  • Login rate limiting: POST /api/v1/auth/login is limited to a burst of 5 requests per IP, replenishing one every 2 seconds (actix-governor).
  • Outbound fetches: feed syncs and the article-reader endpoint only fetch http/https URLs that resolve to public IP addresses (no loopback/private/ link-local, e.g. 127.0.0.1 or the 169.254.169.254 cloud metadata address). Redirects are followed (up to 5 hops), but each redirect target is checked against the same rules before being fetched, so a redirect can't be used to reach an internal address.
  • Stored feed content: <img> tags from synced feed content are sanitized (ammonia) down to src/alt/title before being stored, since they're later rendered with v-html in the frontend.
  • If JWT_SECRET or POSTGRES_PASSWORD are ever leaked (e.g. committed to git), rotate them in .env and restart the backend — rotating JWT_SECRET invalidates every outstanding token as a side effect.

Production setup (Docker)

The whole stack — Postgres, backend, and frontend — runs via Docker Compose. The frontend is built as a static Vue bundle and served by nginx, which also reverse-proxies /api/ to the backend container.

1. Configure environment variables

Same as in development — create a root-level .env from .env.example and fill in strong, unique values for POSTGRES_PASSWORD and JWT_SECRET. Set FRONTEND_ORIGIN to the URL the frontend will actually be served from (e.g. http://<host-ip>:8080 or your domain), since the backend's CORS policy only allows that origin.

2. Build and start everything

docker compose up --build -d

This builds three images and starts them on a shared network:

  • postgres — PostgreSQL 18, data persisted in the postgres_data volume, reachable on localhost:5432
  • backend — multi-stage build (compiles the Rust binary in a rust:slim builder, runs it in a slim debian runtime image); runs embedded Diesel migrations automatically on startup; listens on 0.0.0.0:8001
  • frontend — multi-stage build (compiles the Vue app with node:20-alpine, serves the static bundle with nginx:alpine); listens on 0.0.0.0:8080 and proxies /api/ to the backend service over Docker's internal network

3. Use it

  • From the host machine: http://localhost:8080
  • From another device on the same network (e.g. a phone): http://<host-LAN-IP>:8080

Operating the stack

docker compose ps                  # check container status
docker compose logs -f backend     # follow backend logs
docker compose down                # stop everything (keeps the postgres_data volume)
docker compose down -v             # stop and wipe all data — careful!
docker compose up --build -d       # rebuild after pulling code changes
docker builder prune -af && docker image prune -af  # reclaim disk used by old build layers/images

Each docker compose up --build leaves the previous build's cache layers and images behind, which adds up quickly given how much disk cargo build needs. Run the prune command above after each rebuild (or on a cron job) to reclaim that space.

Optional: hardened deployment — isolated user + rootless Docker

Anyone who can run docker commands effectively has root on the host (container volume mounts can reach the whole filesystem) — being in the docker group is root-equivalent. For a production server, it's worth confining this stack to a dedicated, unprivileged system user running its own rootless Docker daemon, instead of using a system-wide install or adding the user to the docker group.

Rootless Docker runs as a completely independent daemon — separate socket and storage (~/.local/share/docker vs. /var/lib/docker) — so it coexists fine with an existing system-wide Docker install on the same host. Just make sure DOCKER_HOST/PATH point at the rootless daemon when operating on this stack, and that the ports you publish in step 4 below aren't already in use elsewhere.

1. Create the user:

sudo adduser --disabled-password --gecos "" rss-svc

The "normal" rootless Docker setup runs the daemon as a per-user systemd service kept alive via loginctl enable-linger, which depends on systemd-logind/D-Bus. Minimal headless images (DietPi included) often disable or strip those out — and whether re-enabling them survives the distro's own update mechanism is genuinely unclear. Rather than depend on that, the steps below run rootless Docker as an ordinary system-level unit with User=rss-svc — no logind, no D-Bus, no lingering, nothing that can be reset out from under you.

2. Install rootless Docker for that user:

sudo apt install -y uidmap
sudo -u rss-svc -H curl -fsSL https://get.docker.com/rootless -o /tmp/install-rootless.sh
sudo -u rss-svc -H sh /tmp/install-rootless.sh

The installer detects there's no active systemd user session for rss-svc (we're not logging in interactively), so instead of wiring up a per-user service it falls back to a runtime directory under the user's home — and prints the exact paths to use, e.g.:

export XDG_RUNTIME_DIR=/home/rss-svc/.docker/run
export PATH=/home/rss-svc/bin:$PATH
export DOCKER_HOST=unix:///home/rss-svc/.docker/run/docker.sock

Use the values the installer prints for you (add them to ~/.bashrc as rss-svc — e.g. sudo -u rss-svc -H bash) — this is actually convenient for us, since ~/.docker/run is a regular on-disk directory that persists across reboots without any tmpfiles/RuntimeDirectory= trickery.

Now create the system unit at /etc/systemd/system/docker-rss-svc.service, pointing XDG_RUNTIME_DIR at that same directory:

[Unit]
Description=Rootless Docker daemon (rss-svc)
After=network.target

[Service]
User=rss-svc
Group=rss-svc
Environment=PATH=/home/rss-svc/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Environment=XDG_RUNTIME_DIR=/home/rss-svc/.docker/run
ExecStart=/home/rss-svc/bin/dockerd-rootless.sh
Restart=always
RestartSec=2
Delegate=yes
Type=notify
NotifyAccess=all
KillMode=mixed
TasksMax=infinity
LimitNOFILE=1048576

[Install]
WantedBy=multi-user.target

Then enable and start it:

sudo systemctl daemon-reload
sudo systemctl enable --now docker-rss-svc

Verify it came up (as rss-svc, with the .bashrc exports loaded):

docker info

cgroup driver note: rootless Docker defaults to the systemd cgroup driver, which expects a per-user slice (user-<uid>.slice) created by a logind session — something we don't have here by design. If docker compose up --build later fails with open /sys/fs/cgroup/user.slice/user-<uid>.slice/cgroup.controllers: no such file or directory, switch dockerd to manage cgroups itself instead. As rss-svc:

mkdir -p ~/.config/docker
cat > ~/.config/docker/daemon.json << 'EOF'
{
  "exec-opts": ["native.cgroupdriver=cgroupfs"]
}
EOF

then, as your sudo-capable user, sudo systemctl restart docker-rss-svc.

Updating rootless Docker: the daemon and CLI live in /home/rss-svc/bin as a private, self-contained copy of the static binaries — entirely decoupled from the system package manager, so apt upgrade never touches them. To update, stop the daemon, re-run the same installer (it just re-downloads the current stable bundle for your architecture and overwrites the files in ~/bin in place), then start it back up:

sudo systemctl stop docker-rss-svc
sudo -u rss-svc -H curl -fsSL https://get.docker.com/rootless -o /tmp/install-rootless.sh
sudo -u rss-svc -H sh /tmp/install-rootless.sh
sudo systemctl start docker-rss-svc
sudo -u rss-svc -H docker info   # confirm the new version

3. Deploy the stack as rss-svc:

git clone <your-repo-url> ~/rss-reader
cd ~/rss-reader
cp .env.example .env
chmod 600 .env

Fill in .env with strong, unique secrets — openssl rand -hex 32 is a convenient way to generate JWT_SECRET/POSTGRES_PASSWORD.

4. Bind published ports to localhost only. The only thing that needs to reach this stack from outside is the reverse proxy below, and it runs on the same host. Edit docker-compose.yml:

  postgres:
    ports:
      - "127.0.0.1:5432:5432"
  backend:
    ports:
      - "127.0.0.1:8001:8001"
  frontend:
    ports:
      - "127.0.0.1:8080:80"

5. Bring it up:

docker compose up --build -d
docker builder prune -af && docker image prune -af  # reclaim disk used by old build layers/images

6. Firewall (run as your normal sudo-capable user — not rss-svc):

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

With this in place: only SSH and HTTPS are reachable from the network, the reverse proxy is the sole entry point into the app, the whole stack runs in its own user/container/network namespaces with no elevated privileges, and secrets live in a chmod 600 .env owned by an account that can't do anything else on the system.

Optional: Apache reverse proxy (TLS termination)

If you want to expose the app under a domain with HTTPS, put Apache in front of the frontend container (which keeps listening on localhost:8080) and let Apache handle TLS. Enable the required modules first:

sudo a2enmod proxy proxy_http proxy_wstunnel ssl headers

Then a vhost like this proxies everything — including the WebSocket-capable Vite/axios traffic and the /api/ calls the frontend's nginx already forwards to the backend — to the container:

<VirtualHost *:443>
    ServerName rss.example.com

    SSLEngine on
    SSLCertificateFile      /etc/letsencrypt/live/rss.example.com/fullchain.pem
    SSLCertificateKeyFile   /etc/letsencrypt/live/rss.example.com/privkey.pem

    ProxyPreserveHost On
    ProxyPass        / http://127.0.0.1:8080/
    ProxyPassReverse / http://127.0.0.1:8080/

    RequestHeader set X-Forwarded-Proto "https"

    ErrorLog  ${APACHE_LOG_DIR}/rss-error.log
    CustomLog ${APACHE_LOG_DIR}/rss-access.log combined
</VirtualHost>

# Redirect plain HTTP to HTTPS
<VirtualHost *:80>
    ServerName rss.example.com
    Redirect permanent / https://rss.example.com/
</VirtualHost>

Notes for this setup:

  • Set FRONTEND_ORIGIN=https://rss.example.com in your root .env so the backend's CORS check allows the proxied origin, then docker compose up --build -d backend.
  • You no longer need to publish port 8080 to the LAN — change the frontend service's port mapping in docker-compose.yml to "127.0.0.1:8080:80" so only Apache (on the same host) can reach it.
  • Obtain the certificate with certbot --apache -d rss.example.com (via the Certbot Apache plugin), which can also write the vhost and set up auto-renewal for you.

Notes

  • Migrations run automatically at backend startup — no manual diesel step needed in production.
  • Secrets (POSTGRES_PASSWORD, JWT_SECRET, FRONTEND_ORIGIN) come from the root .env, which is gitignored and interpolated into docker-compose.yml via ${VAR} — never commit real secrets.
  • If you need the app reachable from multiple origins (e.g. localhost:8080 on desktop and <lan-ip>:8080 on a phone), the current single-origin CORS check (FRONTEND_ORIGIN) won't allow both — pick the one you'll actually use, or relax the CORS policy in src/main.rs for a self-hosted, trusted-network setup.
S
Description
No description provided
Readme 1.1 MiB
Languages
Rust 46%
JavaScript 31.8%
Vue 17.4%
CSS 3.3%
PLpgSQL 0.6%
Other 0.9%