Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .agents/skills/check-docs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 40 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
189 changes: 189 additions & 0 deletions DOCKERHUB.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
<!-- The Docker Hub overview for `loremdev/doppel`, pushed by the `describe`
job in .github/workflows/release.yml.

Not the README. Docker Hub's page is reached by someone who has already
chosen the image, so it opens with `docker run` rather than with the
installer, and it drops everything about building from source. Every link
is absolute: nothing here is served from a repository checkout.

Docker Hub caps the overview at 25000 bytes and the short description at
100, and truncates silently past that. Keep both well clear.

Every `loremdev/doppel:<version>-alpine` below is rewritten to the tag
being released before the file is pushed, so the copy-paste commands name
a version that exists. The version checked in is the last one released;
the tags table stays illustrative, like the documentation's. -->

![Doppel](https://raw.githubusercontent.com/lorem-dev/doppel/main/docs/assets/icon-128.png)

# 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:** <https://lorem-dev.github.io/doppel/>
- **Source:** <https://github.com/lorem-dev/doppel>
- **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 <container> 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 <https://lorem-dev.github.io/doppel/>. The
Docker page there covers this ground in more detail:
<https://lorem-dev.github.io/doppel/usage/docker/>.
Binary file added docs/assets/icon-128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.