diff --git a/README.md b/README.md
index d3decd4..d3468ef 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,124 @@
-# PZ8 relay
+# pz8-relay
-A simple utility for my iptv needs.
-Upstream links change frequently and need to be updated on devices, which is annoying.
-This is a simple web service that will accept an HTTP request with basic authentication
-and will act as a proxy against a configured HTTP link.
+A small Go service that sits between a frequently-rotating upstream IPTV
+provider and one or more client devices. It exposes stable URLs for the
+playlist and (optionally) a merged EPG, so client devices can be configured
+once and forgotten.
-The proxied URL is configured via an environment variable.
+## What it does
+
+- **Caches the upstream m3u_plus playlist** on a configurable interval and
+ serves it from a stable `/playlist` URL behind basic auth. The streams
+ inside the playlist are still hit directly upstream by clients — only the
+ playlist file is cached.
+- **Optionally fetches one or more XMLTV EPG sources**, merges them, filters
+ channels down to those declared in the playlist, and rewrites their ids to
+ match the playlist's `tvg-id` values. The merged EPG is served at `/epg`
+ and the playlist's `#EXTM3U` header is rewritten so clients pick it up
+ automatically.
+
+Channel id matching is heuristic — the normalizer collapses cosmetic
+divergences (`"Italia 1"` vs `"Italia 1.it"` vs `"Italia 1 HD.it"` all map
+to the same key), which is enough for most Italian providers + open-epg
+sources.
+
+For design background, see [adrs/01-epg-management.md](adrs/01-epg-management.md).
+
+## Endpoints
+
+| Path | Auth | Description |
+| --- | --- | --- |
+| `/playlist` | basic | Cached playlist; `Content-Type: application/vnd.apple.mpegurl` |
+| `/` | basic | Alias for `/playlist` (back-compat) |
+| `/epg` | none | Merged EPG; honours `Accept-Encoding: gzip` (~6× smaller) |
+| `/healthz` | none | 200 once first refresh succeeds, else 503 |
+
+`/epg` is intentionally unauthenticated: XMLTV client apps (TiviMate, IPTV
+Smarters, etc.) auto-discover the `url-tvg` URL from the playlist header
+and fetch it without reusing the playlist's credentials. The threat model
+is low — an anonymous fetcher gets a few hundred KB of TV listings.
+
+## Configuration
+
+All configuration is via environment variables.
+
+| Var | Purpose | Default |
+| --- | --- | --- |
+| `PZ8_RELAY_TARGET_URL` | Upstream m3u_plus playlist URL | **required** |
+| `PZ8_RELAY_USERNAME` | Basic auth username for `/playlist` | **required** |
+| `PZ8_RELAY_PASSWORD` | Basic auth password for `/playlist` | **required** |
+| `PZ8_RELAY_LISTEN_ADDR` | TCP address to listen on | `:8080` |
+| `PZ8_RELAY_PUBLIC_URL` | Public base URL of this relay; used to rewrite `url-tvg` to `/epg` | required when EPG is enabled |
+| `PZ8_RELAY_EPG_URLS` | Comma-separated XMLTV URLs (gzipped or plain) | empty (EPG disabled) |
+| `PZ8_RELAY_EPG_REFRESH` | Refresh interval (Go duration) | `12h` |
+| `PZ8_RELAY_PREFER_PLAYLIST_EPG` | If `true` and the upstream playlist declares `url-tvg`, use that as the *only* EPG source for the cycle; otherwise fall back to `EPG_URLS` | `false` |
+| `PZ8_RELAY_CACHE_DIR` | Where the cached playlist + EPG live | `/var/cache/pz8-relay` |
+| `PZ8_RELAY_EPG_CONTENT_TYPE` | `Content-Type` returned by `/epg` | `application/xml` |
+
+EPG processing is enabled when `PZ8_RELAY_EPG_URLS` is non-empty **or**
+`PZ8_RELAY_PREFER_PLAYLIST_EPG=true`. With EPG disabled, the relay simply
+caches and serves the upstream playlist unchanged.
+
+## How a refresh works
+
+A single background worker drives the refresh on the configured interval
+(starting immediately on boot):
+
+1. Download the upstream playlist, stream-rewrite the `#EXTM3U` header so
+ `url-tvg` points at `/epg`, extract every `tvg-id`, and
+ atomically swap the cached `playlist.m3u` into place.
+2. If EPG is enabled, fetch each configured EPG URL (transparently
+ gunzipping `.gz` URLs) into a per-source temp file.
+3. Walk the sources twice: pass 1 emits unique `` elements (first
+ source wins for duplicates), pass 2 emits `` entries scoped
+ to the source that "owns" each channel. Channels not in the playlist's
+ `tvg-id` set are dropped. Channel ids in the output are rewritten to the
+ exact playlist form.
+4. The result is gzipped on disk as `epg.xml.gz` (XMLTV compresses ~10×)
+ and atomically swapped into place.
+
+If a refresh fails partway through, the previous good cache is preserved
+and the next tick retries. `/playlist` and `/epg` only return 503 before
+the very first successful refresh.
+
+## Running
+
+Local build:
+
+```sh
+go build ./...
+./pz8-relay
+```
+
+Docker:
+
+```sh
+docker build -t pz8-relay .
+docker run --rm -p 8080:8080 --env-file .env pz8-relay
+```
+
+The container runs as the distroless `nonroot` user (uid 65532). The
+default cache directory is created in the image with the right ownership;
+mount a volume on `/var/cache/pz8-relay` if you want the cache to survive
+restarts.
+
+Minimum `.env` for EPG-enabled operation:
+
+```
+PZ8_RELAY_TARGET_URL=https://upstream.example/playlist.m3u
+PZ8_RELAY_USERNAME=alice
+PZ8_RELAY_PASSWORD=hunter2
+PZ8_RELAY_PUBLIC_URL=https://relay.example.com
+PZ8_RELAY_EPG_URLS=https://www.open-epg.com/files/italy1.xml.gz
+```
+
+## Tests
+
+```sh
+go test ./...
+```
+
+Covers id normalization, playlist header rewriting + tvg-id extraction,
+and the EPG two-pass merge (filtering, id rewriting, first-source-wins
+dedup). HTTP wiring and the refresh ticker are not unit-tested; smoke-test
+manually with the endpoints above.