Skip to content
Open
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
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@
# Host port serving the API + web dashboard.
#API_PORT=8001

# Built-in HTTPS for a server with a public IP: point a domain at it, open
# ports 80 and 443, and uncomment both lines. Caddy then gets a certificate on
# its own and serves everything at https://DOMAIN (README "Exposing the
# server"). If you keep a docker-compose.override.yml, add it to the list.
#COMPOSE_FILE=docker-compose.yml:deploy/https/caddy.yml
#DOMAIN=loopback.example.com

# Behind your OWN reverse proxy: the address or CIDR it connects from, so the
# server takes the client address from X-Forwarded-For (per-client login rate
# limit, real addresses in the audit trail). Not needed with the Caddy file
# above, which sets it. Never "*" while port 8001 is reachable by anything
# but the proxy: clients could then forge their address.
#FORWARDED_ALLOW_IPS=

# The AI-coach MCP endpoint, served at /mcp on the same port (README "MCP
# Server"). Set to false to serve no coach endpoint at all.
#MCP_ENABLED=true
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@ tagged `X.Y.Z` and `X.Y` to GHCR (see README "Releases & upgrading").
The running server reports its version at `/api/health` and on the admin
System screen.

## [Unreleased]

### Added

- **Built-in HTTPS for a server with a domain.** Two lines in `.env`
(`COMPOSE_FILE=docker-compose.yml:deploy/https/caddy.yml` and `DOMAIN=`)
add a Caddy container that obtains and renews a Let's Encrypt certificate
and serves the dashboard, API and coach at `https://<DOMAIN>`. It also
closes plain-HTTP port 8001 to everything but the host.
- **`FORWARDED_ALLOW_IPS`** passes through to the server, for installs behind
their own reverse proxy. Without it every proxied client appears as the
proxy: the login rate limit is one bucket shared by everyone and the audit
trail shows the proxy's address on every event. The Caddy file sets it.

## [0.1.17] — 2026-11-12

### Added
Expand Down
19 changes: 19 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,4 +318,23 @@ docker compose up -d --build # Deploy changes
docker compose logs -f backend # Check logs
```

**Built-in HTTPS (`deploy/https/caddy.yml`, unreleased after 0.1.17):** an add-on compose
file enabled with `COMPOSE_FILE=docker-compose.yml:deploy/https/caddy.yml` + `DOMAIN=` in
`.env` (override files with `!override`, same pattern as `deploy/demo`; not compose
profiles, because a profile cannot change the `app` service's ports). Things worth not
re-deriving:
- **Caddy does NOT share the app's network namespace** (`network_mode: service:app`),
tempting as it is (the API would see a loopback peer and trust `X-Forwarded-For` with no
config). Tested: when the app container restarts on its own — crash, `docker restart` —
the sidecar is left in a dead namespace and the site stays down until someone restarts
the sidecar; a plain `docker compose up -d` does not fix it. Any future sidecar
(Tailscale, cloudflared) has the same problem.
- **Client addresses come from uvicorn's proxy-header handling, not app code**:
`client_ip()` and slowapi both read `request.client.host`, which uvicorn rewrites from
`X-Forwarded-For` only for peers in `FORWARDED_ALLOW_IPS` (default: loopback only). The
Caddy file sets `*`, which is sound only because the same file makes port 8001
loopback-only; the two settings must stay together.
- A Tailscale Funnel add-on file was drafted but not shipped: it could not be tested
without a tailnet auth key.

This deployment is exposed via Tailscale Funnel (HTTPS on :8443, proxying the API on :8001) for iPhone app access.
28 changes: 23 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,29 @@ docker compose -f docker-compose.demo.yml down -v

## Exposing the server

The iOS app and dashboard just need to reach port 8001 — over HTTPS, or over HTTP on a network you trust. Options, safest first:
The iOS app syncs in the background, so the server has to be reachable from wherever the phone is — over HTTPS, or over HTTP on a network you trust. Dashboard, API and coach (`/mcp`) all share the one port, so whichever option you pick covers all three.

- **Tailscale (recommended):** install Tailscale on the server and each phone; clients use `http://<machine-name>:8001` inside the tailnet and nothing is exposed to the internet. If the app must also work for people outside your tailnet, [Tailscale Funnel](https://tailscale.com/kb/1223/funnel) can publish the port (e.g. `tailscale funnel --bg --https 8443 8001`).
- **Reverse proxy:** put Caddy / nginx / Traefik with a real certificate in front and forward to `127.0.0.1:8001`.
- **LAN only:** fine for trying it out, but the app syncs in the background, so a phone that leaves the house needs one of the options above.
### Built-in HTTPS (a server with a public IP and a domain)

If the server ends up publicly reachable: login is rate-limited (5/min/IP), there is no registration endpoint (the first-run setup endpoint closes permanently once the admin account exists), and the admin System screen shows an auth audit trail — but public exposure still means **strong passwords on every account**.
Point a domain's DNS at the server, make sure ports 80 and 443 are open, and add two lines to `.env`:

```ini
COMPOSE_FILE=docker-compose.yml:deploy/https/caddy.yml
DOMAIN=loopback.example.com
```

Then `docker compose up -d`. This adds a [Caddy](https://caddyserver.com) container that obtains and renews a Let's Encrypt certificate by itself and serves everything at `https://<DOMAIN>`. It also closes plain-HTTP port 8001 to everything but the host itself, and makes the server see each client's real address, so the login rate limit applies per client and the audit trail shows who connected.

`COMPOSE_FILE` replaces Compose's automatic file discovery: if you keep a `docker-compose.override.yml`, add it to the list (`docker-compose.yml:docker-compose.override.yml:deploy/https/caddy.yml`). On Windows the separator is `;`.

### Other ways

- **Tailscale:** install Tailscale on the server and each phone; clients use `http://<machine-name>:8001` inside the tailnet and nothing is exposed to the internet. If the app must also work for people outside your tailnet, [Tailscale Funnel](https://tailscale.com/kb/1223/funnel) can publish the port (e.g. `tailscale funnel --bg --https 8443 8001`). Needs no domain and no open port, so it suits a home server.
- **Cloudflare Tunnel:** also works from behind a home router, with a domain on Cloudflare. Be aware that Cloudflare terminates the TLS connection, so it can read the health data passing through; with Caddy or Tailscale it is only ever decrypted on your own machine. [`deploy/demo/docker-compose.public.yml`](deploy/demo/docker-compose.public.yml) shows the pattern.
- **Your own reverse proxy:** put nginx / Traefik / Caddy with a real certificate in front and forward to `127.0.0.1:8001`. Set `FORWARDED_ALLOW_IPS` in `.env` to the address the proxy connects from, or every client shares one rate limit (see [Configuration](#configuration)).
- **LAN only:** fine for trying it out, but a phone that leaves the house stops syncing.

If the server ends up publicly reachable: login is rate-limited (5/min per client address), there is no registration endpoint (the first-run setup endpoint closes permanently once the admin account exists), and the admin System screen shows an auth audit trail — but public exposure still means **strong passwords on every account**.

## Backups

Expand Down Expand Up @@ -166,6 +182,8 @@ Everything is configured through environment variables in the root `.env` (read
| `POSTGRES_PASSWORD` / `POSTGRES_USER` / `POSTGRES_DB` | `training-api` | Database credentials (not published outside the compose network; also fed to the app as `DATABASE_URL`) |
| `API_PORT` | `8001` | Host port for the API + dashboard |
| `MCP_ENABLED` | `true` | Serve the coach MCP at `/mcp` on the same port (see [MCP Server](#mcp-server)). `false` removes the endpoint |
| `FORWARDED_ALLOW_IPS` | *(unset)* | Behind a reverse proxy: the address(es) or CIDR the proxy connects from, whose `X-Forwarded-For` the server trusts for the client address (login rate limit, audit trail). Unset, only loopback is trusted and every proxied client looks like the proxy. `deploy/https/caddy.yml` sets this for you. Never use `*` while port 8001 is reachable by anything but the proxy |
| `DOMAIN` | *(unset)* | Hostname for built-in HTTPS, used with `deploy/https/caddy.yml` (see [Exposing the server](#exposing-the-server)) |
| `BACKUP_HOST_DIR` | `./backups` | Host directory the server writes its `pg_dump` backups to (and reports freshness from) |
| `BACKUP_ENABLED` | `true` | Server-managed backups: nightly dump, catch-up after downtime, pre-migration dump. `false` = bring your own (see [Backups](#backups)) |
| `BACKUP_TIME` | `03:30` | Nightly backup time, container-local (set `TZ` to make it your local time) |
Expand Down
9 changes: 6 additions & 3 deletions backend/app/rate_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
Lives in its own module so routes and ``main`` can import the same limiter
instance without a circular import.

Note: behind a reverse proxy (Tailscale Funnel) ``get_remote_address`` sees the
proxy's address, so the login limit is effectively shared across clients. That
is acceptable as a brute-force backstop for a self-hosted household instance.
The key is the client address as uvicorn reports it. Behind a reverse proxy
that is the proxy's own address — one bucket shared by every client, so a
single client hammering the login locks everyone out — unless the proxy's
address is listed in FORWARDED_ALLOW_IPS, in which case uvicorn takes the
client from X-Forwarded-For. deploy/https/caddy.yml sets that up; see README
"Exposing the server".
"""

from slowapi import Limiter
Expand Down
48 changes: 48 additions & 0 deletions deploy/https/caddy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Built-in HTTPS with a real certificate — layered on top of docker-compose.yml.
#
# For a server with a public IP (a VPS) and a domain whose DNS points at it.
# Caddy obtains and renews a Let's Encrypt certificate on its own; ports 80
# and 443 must be reachable from the internet. Enable it from the root .env:
#
# COMPOSE_FILE=docker-compose.yml:deploy/https/caddy.yml
# DOMAIN=loopback.example.com
#
# then `docker compose up -d`. Dashboard, API and coach (/mcp) are then at
# https://<DOMAIN>. See README "Exposing the server".

services:
app:
# Plain HTTP stays on the host's loopback only — handy for a local health
# check, closed to the internet. Everything else arrives through Caddy.
ports: !override
- "127.0.0.1:${API_PORT:-8001}:8001"
environment:
# Take the client address from X-Forwarded-For, so the login rate limit
# and the audit trail see each client instead of one shared proxy
# address. "*" trusts the header from any peer, which is sound ONLY
# because of the loopback-only port above: the peers left are Caddy
# (which sets the header from the real connection and discards any a
# client sent) and processes on this host. Publish 8001 more widely and
# anyone can forge their address — keep these two settings together.
- FORWARDED_ALLOW_IPS=*

caddy:
image: caddy:2
container_name: caddy__training-api
command: caddy reverse-proxy --from ${DOMAIN:?set DOMAIN in .env to the hostname that points at this server} --to app:8001
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
# Certificates and the ACME account. Losing this volume just means a
# fresh certificate, but repeated loss runs into Let's Encrypt limits.
- caddy_data:/data
- caddy_config:/config
depends_on:
- app
restart: unless-stopped

volumes:
caddy_data:
caddy_config:
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ services:
# Coach MCP at /mcp on this same port (on by default). Passes through
# only when set in the root .env.
- MCP_ENABLED
# Behind a reverse proxy: whose X-Forwarded-For to trust for the client
# address (see README "Configuration"). Passes through only when set;
# deploy/https/caddy.yml sets it itself.
- FORWARDED_ALLOW_IPS
volumes:
# Backup dir: the server writes its own pg_dump files here (nightly +
# pre-migration + on demand from the System screen, which also reports
Expand Down
Loading