Self-hosting annota
annota ships as a single statically-linked binary with SQLite embedded. No external database, no runtime dependencies.
Docker Compose
The fastest way to get started:
curl -O https://code.zeidler.dev/tools/annota/raw/branch/main/docker/docker-compose.yml
# Edit TNYRSS_ADMIN_PASS before first run
docker compose up -d
The compose file uses a named volume data for the database, saved articles, and cache. To pin a specific version:
image: registry.zeidler.dev/docker/tools/annota:0.5.0
A dev build is published on every push to main as annota-dev:latest. Use the versioned stable image for production.
Single binary
Download a pre-built binary from the releases page or build from source:
# Build from source (requires Go 1.26+)
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o annota .
cat > config.toml <<'EOF'
admin_user = "admin"
admin_pass = "changeme"
db_path = "/var/lib/annota/feeds.db"
listen_addr = ":8080"
feed_title = "annota"
EOF
./annota config.toml
The binary reads config.toml in the current directory by default, or from the path given as the first argument.
Helm / Kubernetes
Two charts are published. Use the stable chart for production:
# Stable — updated on every release
helm install annota oci://registry.zeidler.dev/helm/annota \
--set ingress.enabled=true \
--set ingress.host=annota.example.com \
--set ingress.certIssuer=letsencrypt \
--set admin.pass=changeme \
-n annota --create-namespace
# Dev — updated on every push to main
helm install annota-dev oci://registry.zeidler.dev/helm/annota-dev \
--set ingress.host=annota-dev.example.com \
--set admin.pass=changeme \
-n annota-dev --create-namespace
| Value | Default | Description |
|---|---|---|
image.tag | (chart appVersion) | Image tag |
admin.pass | default | Bootstrap admin password |
admin.existingSecret | "" | Secret with TNYRSS_ADMIN_USER / TNYRSS_ADMIN_PASS |
ingress.enabled | false | Enable Traefik ingress |
ingress.host | annota.example.com | Hostname |
ingress.certIssuer | "" | cert-manager ClusterIssuer |
persistence.size | 1Gi | PVC storage size |
config.baseURL | "" | Canonical public URL (enables changelog auto-subscribe) |
Config file
admin_user = "admin"
admin_pass = "changeme"
db_path = "feeds.db"
listen_addr = ":8080"
feed_title = "annota"
base_url = "" # e.g. "https://annota.example.com"
readlater_dir = "readlater"
articles_dir = "articles"
cache_dir = "cache"
fetch_interval_long = "30m"
fetch_interval_short = "2m"
allow_signup = false # true = open registration
feeds = [
"https://hnrss.org/frontpage",
]
sync_webdav = false
sync_git = false
Environment variables
Every config key can be overridden with a TNYRSS_* environment variable.
| Variable | Config key |
|---|---|
TNYRSS_ADMIN_USER | admin_user |
TNYRSS_ADMIN_PASS | admin_pass |
TNYRSS_DB_PATH | db_path |
TNYRSS_LISTEN_ADDR | listen_addr |
TNYRSS_FEED_TITLE | feed_title |
TNYRSS_BASE_URL | base_url |
TNYRSS_READLATER_DIR | readlater_dir |
TNYRSS_ARTICLES_DIR | articles_dir |
TNYRSS_CACHE_DIR | cache_dir |
TNYRSS_ALLOW_SIGNUP | allow_signup |
TNYRSS_FETCH_INTERVAL_LONG | fetch_interval_long |
TNYRSS_FETCH_INTERVAL_SHORT | fetch_interval_short |
TNYRSS_SYNC_WEBDAV | sync_webdav |
TNYRSS_SYNC_GIT | sync_git |
Data & backups
All data lives in a single SQLite file (db_path). Back it up while the server is idle or use SQLite’s online backup:
sqlite3 feeds.db ".backup feeds.db.bak"
Saved articles and notes are plain Markdown files in readlater_dir and articles_dir. Back those up too if you use the write features.
Updates
annota runs database migrations automatically on startup. To update:
# Docker Compose
docker compose pull && docker compose up -d
# Helm
helm upgrade annota oci://registry.zeidler.dev/helm/annota
Take a backup before upgrading — downgrading after migrations ran may not work.
Registration
Three ways to grant access:
- Admin creates account — go to
/admin/users - Invite token — admin generates a one-use token; recipient enters it on
/signup - Open registration — set
allow_signup = trueorTNYRSS_ALLOW_SIGNUP=true
The signup endpoint is rate-limited to 5 attempts per IP per hour regardless of mode.