diff --git a/.agents/skills/check-docs/SKILL.md b/.agents/skills/check-docs/SKILL.md
index 67da2c4..fbdc767 100644
--- a/.agents/skills/check-docs/SKILL.md
+++ b/.agents/skills/check-docs/SKILL.md
@@ -5,12 +5,17 @@ description: Use before a release, or after changing a command, a flag, a config
# Check the documentation
-Documentation in this repository has four homes, and they go stale in
+Documentation in this repository has five homes, and they go stale in
different ways:
- `README.md` -- the entry point. What Doppel is, what it does and does not do
yet, how to run it.
- `docs/` -- the mkdocs site, in three sections (below).
+- `DOCKERHUB.md` -- the Docker Hub overview, pushed by the release workflow.
+ Written for someone who has already chosen the image, so it opens with
+ `docker run` and never mentions building from source. Check it against
+ `docs/usage/docker.md`, which covers the same ground at length: a fact that
+ changed in one and not the other is the failure mode here.
- `AGENTS.md` -- addressed to agents. Holds rules and pointers, never a second
copy of anything in `docs/`.
- Doc comments in the code -- where the reasoning lives.
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c698135..86b6156 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -245,6 +245,46 @@ jobs:
# here would be a fixed string and would give both architectures the
# same binary.
+ # Docker Hub's page is the one place an image is described to someone who
+ # never opens the repository, and it stays whatever was last pushed to it
+ # -- including empty. DOCKERHUB.md is written for that page rather than
+ # being the README, which opens with an installer the image's reader has
+ # no use for.
+ #
+ # Skipped for a pre-release, on the same reasoning as the moving tags: the
+ # page would tell everyone to pull an rc.
+ - name: Prepare the overview for the version being released
+ if: ${{ !contains(github.event.inputs.tag || github.ref_name, '-') }}
+ env:
+ TAG: ${{ github.event.inputs.tag || github.ref_name }}
+ run: |
+ set -euo pipefail
+ version="${TAG#v}"
+ # Only the copy-paste commands, which name the image. The tags table
+ # uses bare `1.2.3-alpine` to show the shape and is left alone.
+ sed -i -E "s|loremdev/doppel:[0-9][^[:space:]]*-alpine|loremdev/doppel:${version}-alpine|g" \
+ DOCKERHUB.md
+ # Drop the file's own note to maintainers. Docker Hub renders
+ # markdown, and every overview on the hub that shows an image uses
+ # `![]()` rather than a tag -- so whether it strips HTML or escapes
+ # it is not something to find out on a live page. Deleting the
+ # comment makes the question moot. The range form assumes the
+ # comment does not wrap anything meant to be published, which is
+ # what the one at the top of DOCKERHUB.md says it is for.
+ sed -i '//d' DOCKERHUB.md
+ head -4 DOCKERHUB.md
+ grep -n 'loremdev/doppel:' DOCKERHUB.md
+
+ - name: Push the overview to Docker Hub
+ if: ${{ !contains(github.event.inputs.tag || github.ref_name, '-') }}
+ uses: peter-evans/dockerhub-description@v5
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+ repository: loremdev/doppel
+ readme-filepath: ./DOCKERHUB.md
+ short-description: "CLI-driven HTTP reverse proxy that forwards, degrades, or mocks a backend"
+
publish:
name: Publish the release
needs: build
diff --git a/DOCKERHUB.md b/DOCKERHUB.md
new file mode 100644
index 0000000..45127aa
--- /dev/null
+++ b/DOCKERHUB.md
@@ -0,0 +1,189 @@
+
+
+
+
+# Doppel
+
+A CLI-driven HTTP reverse proxy. The name is from "doppelganger": it stands in
+front of, or in place of, a real backend, so clients of that backend can be
+developed and tested against a realistic, deliberately degraded, or entirely
+absent upstream. It forwards traffic to a configured upstream, injects
+controlled latency and loss, and replaces selected endpoints with templated
+mock responses.
+
+- **Documentation:**
+- **Source:**
+- **Licence:** Apache-2.0
+
+## Tags
+
+Published for `linux/amd64` and `linux/arm64`.
+
+| Tag | Moves? | Points at |
+|---|---|---|
+| `1.2.3-alpine` | no | exactly that release |
+| `1.2-alpine` | yes | the newest patch of 1.2 |
+| `1-alpine` | yes | the newest minor of 1 |
+
+Every tag names its base, the convention the images this one sits beside
+use (`postgres:17.6-alpine`, `node:22-alpine`). That leaves the unsuffixed
+names free, so a second base could be published later without renaming
+anything anyone has already pinned.
+
+There is deliberately **no `latest`**. A tag that moves is one an unpinned
+deployment follows into a release nobody reviewed. Pin the full version in
+anything that matters.
+
+A pre-release (`1.2.3-rc.1`) is published as `1.2.3-rc.1-alpine` only. It never
+takes `1.2-alpine` or `1-alpine` with it.
+
+## Quick start
+
+The image ships no configuration. That is deliberate: a default that quietly
+proxied somewhere unintended would be worse than a container that refuses to
+start.
+
+```bash
+docker run --rm \
+ -p 8080:8080 -p 8081:8081 \
+ -v "$PWD/main.yaml:/etc/doppel/main.yaml:ro" \
+ -v doppel-templates:/var/lib/doppel/templates \
+ loremdev/doppel:0.1.0-alpine
+```
+
+`8080` carries proxied traffic, `8081` the admin API.
+
+A minimal `main.yaml`:
+
+```yaml
+server:
+ host: "0.0.0.0"
+ port: 8080
+
+admin:
+ host: "0.0.0.0"
+ port: 8081
+ tokens: []
+ access: {}
+ upload:
+ limit: 1Mi
+
+proxies:
+ - name: backend
+ type: http
+ url: "https://example.com/"
+ resolve:
+ type: default
+```
+
+Both listeners must bind `0.0.0.0` rather than `127.0.0.1`, or nothing outside
+the container can reach them.
+
+## The templates volume is not optional
+
+Templates uploaded through the admin API are written to disk at upload and read
+from disk per request. Without a volume they live in the container's writable
+layer and go with it -- `docker stop`, `docker run` again, and every uploaded
+template is gone. Nothing reports this: the mock that named the file answers
+with a render error on the next request, which reads like a broken template
+rather than a missing volume.
+
+```yaml
+templates:
+ dir: /var/lib/doppel/templates
+```
+
+A relative `./templates` also works: the image's working directory is
+`/var/lib/doppel`, so it resolves to the same place.
+
+## Compose
+
+```yaml
+services:
+ doppel:
+ image: loremdev/doppel:0.1.0-alpine
+ ports:
+ - "8080:8080"
+ - "8081:8081"
+ volumes:
+ - ./main.yaml:/etc/doppel/main.yaml:ro
+ - doppel-templates:/var/lib/doppel/templates
+ environment:
+ DOPPEL_ADMIN_TOKENS: '{"ci":{"token":"...","group":"admin"}}'
+ healthcheck:
+ test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8081/status"]
+ interval: 5s
+ timeout: 3s
+ start_period: 5s
+ retries: 5
+
+volumes:
+ doppel-templates:
+```
+
+`/status` needs no token by default and answers only once the runtime is
+compiled and both listeners are bound. `-f` matters: without it `curl` exits 0
+on a 500 and the check passes for answering at all rather than for answering
+correctly.
+
+## Tokens
+
+`DOPPEL_ADMIN_TOKENS` keeps secrets out of the configuration file that gets
+mounted in:
+
+```bash
+docker run --rm \
+ -e DOPPEL_ADMIN_TOKENS='{"ci":{"token":"...","group":"admin"}}' \
+ ...
+```
+
+## Reloading
+
+`doppel config reload` talks to a control socket inside the container. Two ways
+in:
+
+```bash
+# Through the admin API, if it is enabled and you hold a token.
+curl -X POST -H "X-Proxy-Authorization: Bearer $TOKEN" \
+ http://localhost:8081/api/v1/config/reload
+
+# Or the same binary, inside the container.
+docker exec doppel config reload --socket /tmp/doppel.sock
+```
+
+Editing a bind-mounted `main.yaml` on the host changes the file the container
+reads, so a reload picks it up without restarting anything.
+
+## What the image contains
+
+Alpine, `ca-certificates`, `tini`, and one statically linked binary. It runs as
+a non-root user, so a configuration asking for a port below 1024 fails at
+bind -- the right answer in a container; publish a low port on the host
+instead.
+
+`ca-certificates` is not optional: without a trust store a proxy cannot verify
+an `https` upstream, and every request fails with `UPSTREAM_ERROR`.
+
+Doppel runs under `tini`, so `docker stop` delivers `SIGTERM` and the graceful
+drain runs -- in-flight requests finish, then the process exits.
+
+## More
+
+Full documentation, including fault injection, mocks, the admin API, PostgreSQL
+storage and observability, is at . The
+Docker page there covers this ground in more detail:
+.
diff --git a/docs/assets/icon-128.png b/docs/assets/icon-128.png
new file mode 100644
index 0000000..70ab06a
Binary files /dev/null and b/docs/assets/icon-128.png differ