Using annota Self-hosting

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
ValueDefaultDescription
image.tag(chart appVersion)Image tag
admin.passdefaultBootstrap admin password
admin.existingSecret""Secret with TNYRSS_ADMIN_USER / TNYRSS_ADMIN_PASS
ingress.enabledfalseEnable Traefik ingress
ingress.hostannota.example.comHostname
ingress.certIssuer""cert-manager ClusterIssuer
persistence.size1GiPVC 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.

VariableConfig key
TNYRSS_ADMIN_USERadmin_user
TNYRSS_ADMIN_PASSadmin_pass
TNYRSS_DB_PATHdb_path
TNYRSS_LISTEN_ADDRlisten_addr
TNYRSS_FEED_TITLEfeed_title
TNYRSS_BASE_URLbase_url
TNYRSS_READLATER_DIRreadlater_dir
TNYRSS_ARTICLES_DIRarticles_dir
TNYRSS_CACHE_DIRcache_dir
TNYRSS_ALLOW_SIGNUPallow_signup
TNYRSS_FETCH_INTERVAL_LONGfetch_interval_long
TNYRSS_FETCH_INTERVAL_SHORTfetch_interval_short
TNYRSS_SYNC_WEBDAVsync_webdav
TNYRSS_SYNC_GITsync_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:

The signup endpoint is rate-limited to 5 attempts per IP per hour regardless of mode.