A tiny reverse proxy that holds the door — put it in front of any app to gate access behind a single shared password. No users, no database, no OAuth. Just one password and a login page.
- Single shared password — no user accounts, no database
- Clean dark-themed login page (or bring your own with Jinja2 templates)
- Runs as a Docker sidecar in front of any web app
- HMAC-SHA256 signed session cookies
- Streaming reverse proxy (handles large uploads/downloads without buffering)
- Constant-time password comparison
- Per-IP rate limiting on login (5 attempts / 60s)
- Structured tracing output (compact or JSON)
- Health check endpoint for container orchestrators
- Graceful shutdown on SIGTERM
- Layered config: defaults →
hodor.toml→ environment variables - Built with Rust, runs from a
scratchimage (~5MB)
# docker-compose.yml
services:
gate:
image: ghcr.io/michidk/hodor:latest
ports:
- "8080:8080"
environment:
PASSWORD: "changeme" # the login password
UPSTREAM: "http://app:80"
SECRET: "changeme" # signs session cookies (generate with: openssl rand -hex 32)
depends_on:
- app
app:
image: traefik/whoamidocker compose upOpen http://localhost:8080 — you'll see the login page. Enter the password, and you're proxied through to the app.
Hodor uses layered configuration. Each layer overrides the previous:
- Defaults — sensible built-in values
hodor.toml— optional config file in the working directory- Environment variables — override everything (uppercase, e.g.
PASSWORD)
| Key | Env var | Required | Default | Description |
|---|---|---|---|---|
password |
PASSWORD |
yes | The shared password | |
upstream |
UPSTREAM |
yes | Backend URL to proxy to (e.g. http://app:3000) |
|
secret |
SECRET |
no | random | Cookie signing key. Set this to persist sessions across restarts |
listen |
LISTEN |
no | :8080 |
Listen address |
title |
TITLE |
no | Password Required |
Login page heading |
template |
TEMPLATE |
no | built-in | Path to a custom HTML login page template |
error_template |
ERROR_TEMPLATE |
no | built-in | Path to a custom HTML error page template |
session_ttl |
SESSION_TTL |
no | 86400 |
Session duration in seconds (default: 24h) |
secure_cookie |
SECURE_COOKIE |
no | false |
Set true to add the Secure flag to cookies (requires HTTPS) |
log_format |
LOG_FORMAT |
no | compact |
Tracing output format: compact or json |
| — | RUST_LOG |
no | info |
Log level filter (e.g. debug, hodor=trace) |
# hodor.toml
password = "changeme"
upstream = "http://app:3000"
secret = "changeme" # generate with: openssl rand -hex 32
title = "Restricted Area"
session_ttl = 3600
secure_cookie = trueEnvironment variables always win. Set PASSWORD=override and it takes precedence over password in the TOML file.
Request → hodor
├─ /_gate/health → 200 ok (bypass auth)
├─ Has valid session cookie? → Reverse proxy to UPSTREAM
└─ No cookie? → Show login page
└─ POST /_gate/login
├─ Rate limited? → 429
├─ Password correct? → Set cookie, redirect back
└─ Wrong? → Show login page with error
/_gate/login— login form submission (POST) / redirect to gate (GET)/_gate/logout— clears session cookie/_gate/health— returnsok(for liveness/readiness probes)
All other paths are proxied to the upstream.
- Streams request and response bodies without buffering (safe for large files)
- Sets
X-Forwarded-ForandX-Forwarded-Protoheaders on proxied requests - Strips hop-by-hop headers (Connection, Transfer-Encoding, etc.)
- Forwards the upstream's
Hostheader - WebSocket proxying is not yet supported (returns 501)
Hodor ships with a built-in dark-themed login page. To use your own login page, set template to the path of an HTML file:
environment:
TEMPLATE: /etc/hodor/login.html
volumes:
- ./my-login.html:/etc/hodor/login.html:roTemplates use Jinja2 syntax (via minijinja). The following variables are available:
| Variable | Type | Description |
|---|---|---|
title |
string | The configured title (auto-escaped) |
show_error |
bool | true when the user entered a wrong password |
The built-in template (src/template.html) is a good starting point for custom designs. Here's a minimal example showing the required structure:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title>
<style>
* { box-sizing: border-box; margin: 0; }
body {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
font-family: system-ui, sans-serif;
background: #f5f5f5;
}
.card {
width: 100%;
max-width: 380px;
background: #fff;
border-radius: 12px;
padding: 32px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
}
h1 { margin-bottom: 20px; font-size: 1.4rem; }
input, button {
width: 100%;
padding: 10px 14px;
border: 1px solid #ddd;
border-radius: 8px;
font: inherit;
}
input { margin-bottom: 12px; }
button { background: #111; color: #fff; border: none; cursor: pointer; }
.error {
display: {% if show_error %}block{% else %}none{% endif %};
margin-bottom: 12px;
padding: 10px;
border-radius: 8px;
background: #fef2f2;
color: #dc2626;
}
</style>
</head>
<body>
<main class="card">
<h1>{{ title }}</h1>
<div class="error">Wrong password.</div>
<form method="post" action="/_gate/login">
<input type="hidden" name="redirect" value="/">
<input name="password" type="password" placeholder="Password" autocomplete="current-password" autofocus required>
<button type="submit">Continue</button>
</form>
</main>
<script>
const redirect = document.querySelector('input[name="redirect"]');
if (redirect) redirect.value = window.location.pathname + window.location.search + window.location.hash || '/';
</script>
</body>
</html>- The form must POST to
/_gate/loginwith apasswordfield - Include a
redirecthidden field (populated via JS) so users return to the page they were trying to access - Use
{% if show_error %}to conditionally show error messages
Hodor also ships with a built-in styled error page for upstream failures and unsupported WebSocket upgrades. To customize it, set error_template to the path of an HTML file:
environment:
ERROR_TEMPLATE: /etc/hodor/error.html
volumes:
- ./my-error.html:/etc/hodor/error.html:roThe built-in error template (src/error_template.html) receives these variables:
| Variable | Type | Description |
|---|---|---|
title |
string | The configured title (auto-escaped) |
status_code |
number | HTTP status code such as 502 or 501 |
heading |
string | Short error heading |
message |
string | Human-readable error message |
cargo build --releasePASSWORD=secret UPSTREAM=http://localhost:3000 ./target/release/hodorBuild locally:
docker build -t hodor .
docker run -e PASSWORD=secret -e UPSTREAM=http://host.docker.internal:3000 -p 8080:8080 hodorHodor exposes /_gate/health which returns 200 ok — use it for liveness and readiness probes.
Since hodor runs from a scratch image, there's no shell or utilities inside the container. Use an external probe or your orchestrator's native HTTP health check:
# Kubernetes
livenessProbe:
httpGet:
path: /_gate/health
port: 8080
initialDelaySeconds: 2
periodSeconds: 10