A single-binary HTTP API gateway in Go. Path-prefix routing to internal services, a Redis response cache, and JWT authentication it owns end to end.
Early stage. Everything in this document ships today, and every claim in it points at the file that implements it. The Limitations section is not a roadmap — it is a list of what the gateway does not do yet. Read it before you deploy it.
- One binary that carries its own configuration.
static/config.yamlis compiled into the executable withgo:embed; nothing has to be mounted alongside it (static/embed.go,app/config.go). - Routing by path prefix. Each configured service owns a path; a request is
forwarded to the host behind the longest matching prefix, and an unmatched
path is a
404instead of a guess (pkg/request/call.go). - Internal vs external services. Services are split into two lists, and both require a token — the lists differ only in who wins a routing tie (see Routing).
- Response cache in Redis, cache-aside. A fingerprint of the request keys the
response; a hit skips the upstream entirely, and a Redis outage degrades
latency instead of availability (
pkg/cache). - It owns its users. Register, log in, rotate, log out, backed by Postgres,
with bcrypt passwords and hashed refresh tokens (
pkg/auth). - Refresh rotation with reuse detection. Replaying a rotated refresh token
revokes every session the account holds (
pkg/auth/service.go). - Signups are gated by one-time registration tokens, minted by an operator
command, so the gateway is not an open registration form
(
cmd/admin/genregtoken). - Operational floor. Panic recovery, a
10sReadHeaderTimeout, a global rate limit, and gracefulSIGINT/SIGTERMshutdown that drains in-flight requests (app).
cmd/server ──► app ── the only adapter: HTTP, mux, Redis, GORM, YAML
│
├──► pkg/request proxy + cache orchestration (the gateway itself)
├──► pkg/auth accounts, sessions, JWT issuer, bearer middleware
├──► pkg/cache Cache[T] port + Redis adapter
├──► pkg/repo Repository[T] port + GORM adapter
└──► pkg/log logging that reads method/path off the context
The one rule that shapes the whole codebase: no package under pkg/ decides a
status code or renders an error. A domain names what went wrong with a
sentinel, and app/ decides how that looks over the wire. pkg/ never imports
app/, and never imports a driver — redis.Nil becomes cache.ErrNotFound and
gorm.ErrRecordNotFound becomes repo.ErrNotFound at the adapter, so callers
classify with errors.Is without knowing which database or cache is wired in.
The practical payoff: Client.Request returns errors, never status codes, and
every failure mode is reachable from a test without a network, a database or a
sleep.
┌──────────────────────────────┐
client ──── request ──────► │ loggingMiddleware │ stamps method + path on the
│ │ context, access log on the way out
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ rateLimiterMiddleware │ intended 5 rps, burst 10, one global
│ │ bucket — rebuilt per request, so it
│ │ never refuses one (see Limitations)
└──────────────┬───────────────┘
▼
gorilla/mux routing
┌───────────────────┴───────────────────┐
▼ ▼
GET /health ──► 200 {"status":"ok"} everything else ──► authMiddleware
liveness only, no token, Bearer access token verified (stateless)
no dependency checks
│
POST /auth/{register,login,refresh,logout} │ └──► Principal in the context
jsonMiddleware, no bearer needed │ missing or invalid ──► 401
│ │
│ register / login │
│ ──► 201 / 200 {access_token, …} │
│ refresh ──► 200 new pair ▼
│ logout ──► 204, always request.Client.Request
▼ │
│ ┌──────────────────┴───────────────────┐
│ │ NewCall │
│ │ · match the path against services │
│ │ · rewrite the prefix → upstream URL │
│ │ · fingerprint the request → cache key│
│ └──────────────────┬───────────────────┘
│ no match ──► 404 not found
│ │
│ ┌──────────────────▼───────────────────┐
│ │ cache.Get │
│ │ hit ──────────────────────────► serve the
│ │ miss / Redis down ──► log, continue│
│ └──────────────────┬───────────────────┘
│ ▼
│ │ upstream exchange, 10s timeout
│ │ dial/timeout ──► 502
│ ▼ body snapshotted once
│ │
│ ├──► streamed to the client
│ └──► goroutine: cache.Set (15s TTL)
▼ │ a write failure is logged, not returned
postgres (auth tables)
Four things in that picture are worth stating out loud, because they are the difference between a gateway that survives its dependencies and one that does not:
- The cache write is not on the response path. By the time the snapshot is
cached, the client already has its bytes. A Redis write failure is logged and
dropped — it can never fail a request that has already been answered
(
pkg/request/client.go). - A cache outage is not a failure.
Getreturns an error rather than pretending the key was absent, and the client treats a miss and an outage the same way: go upstream, and log the difference. Redis being down costs you latency and upstream load, not availability. - The snapshot is bytes, not a shared reader. The upstream body is read once
into a
[]byteand the handler and the cache goroutine each get their own reader over it, so a response cannot be half-read by one and drained by the other. - Every proxied path needs a token. The
externallist is not a public list; see Routing for what the two lists actually do.
The gateway owns its users end to end: there is no external identity provider.
Tables are created on startup (AutoMigrate), so Postgres must be reachable
before the gateway will serve.
client gateway postgres
│ │ │
│ POST /auth/register │ │
├──────────────────────────►│ validate email / username / │
│ │ password, then ONE tx: │
│ ├─ consume the registration ────►│ atomic, so two races
│ │ token (decides in the DB) │ cannot both win
│ ├─ create the user (bcrypt) ─────►│
│ ├─ mint access + refresh ────────►│ refresh stored as SHA-256
│ 201 {access_token, …} │◀──────────────────────────────┤
│◀──────────────────────────┤ │
│ │ │
│ POST /auth/login │ │
├──────────────────────────►│ bcrypt compare ───────────────►│
│ 200 {access_token, …} │◀──────────────────────────────┤ unknown email and wrong
│◀──────────────────────────┤ │ password answer the same
│ │ │
│ POST /auth/refresh │ │
├──────────────────────────►│ verify signature + kind │
│ ├─ look the session up by JTI ──►│ compare SHA-256 of the token
│ │ │
│ │ already revoked? │
│ │ │ │
│ │ └──► revoke EVERY ───►│ a replay and a theft look
│ │ session of that │ identical, so both are
│ │ user, 401 │ answered the same way
│ │ │ │
│ │ transaction: mint the │
│ │ new pair, mark the old │
│ │ one revoked ────────────►│ replaced_by = new JTI
│ 200 new pair │◀──────────────────────────────┤
│◀──────────────────────────┤ │
│ │ │
│ POST /auth/logout │ │
├──────────────────────────►│ revoke this refresh token ───►│
│ 204 (always) │◀──────────────────────────────┤ idempotent by design
│◀──────────────────────────┤ │
| Endpoint | Body | Success | What it does |
|---|---|---|---|
POST /auth/register |
email, username, password, registration_token |
201 |
Burns a one-time token and creates the account in the same transaction that starts the session |
POST /auth/login |
email, password |
200 |
Returns an access/refresh pair |
POST /auth/refresh |
refresh_token |
200 |
Rotates the refresh token; a replayed one revokes every session the account holds |
POST /auth/logout |
refresh_token |
204 |
Revokes one session. Always 204, whatever the token was |
A successful register, login or refresh answers:
{
"access_token": "eyJhbGciOiJIUzI1NiIs…",
"refresh_token": "eyJhbGciOiJIUzI1NiIs…",
"token_type": "Bearer",
"expires_at": "2026-01-01T12:15:00Z",
"user": { "id": 1, "email": "ada@example.com", "username": "ada", "role": "user", "is_active": true }
}user is present on register and login, and omitted on a bare refresh.
Token model. Access tokens are HS256, signed with auth.access_secret,
default 15 minutes, and are their own proof — verifying one hits no database, so
the proxy stays stateless. Refresh tokens are signed with a different secret
(auth.refresh_secret, default 7 days); sharing one secret between the two kinds
is rejected at startup. Issuer and audience are checked on every verification, the
algorithm is pinned so a token signed with another one never reaches the key, and
auth.clock_skew (5s) is the leeway allowed on time checks. Only the SHA-256 of
a refresh token is persisted, so a database leak cannot be replayed against the
gateway.
Registration tokens. Accounts are not self-service: an operator mints a one-time token, printed once and stored only as a hash.
make genregtoken # or:
go run ./cmd/admin/genregtoken -issued-by alice -ttl 24hThe command loads the same embedded config as the server, so it must run where the gateway runs.
The rate limiter runs before the router, so
/auth/loginand/healthshare the same global 5 rps bucket as the proxy. In practice nothing is throttled at all today — see Limitations.
Matching happens in findServiceConfigForUri (pkg/request/call.go). Given this
configuration:
services:
internal: # a strictly deeper path wins the tie
- path: admin
host: admin-service:8000
- path: admin/reports
host: reports-service:9000
external:
- path: public
host: public-service:8000these are the resolved upstreams, checked against the matcher itself rather than reasoned about:
| Request | Resolves to | Auth flag | Upstream URL |
|---|---|---|---|
/public/logo.png |
public |
external | http://public-service:8000/logo.png |
/admin/users |
admin |
internal | http://admin-service:8000/users |
/admin/reports/daily |
admin/reports |
internal | http://reports-service:9000/daily |
/unknown/path |
— | — | 404 not found |
The rules, in the order they apply:
- Externals are scanned first and the first match wins.
- Internals override only when strictly deeper, compared by counting
/in the configured path. Equal depth does not override, so a path listed in both lists is served by the external entry. - The rewrite replaces the matched prefix with the host, after dropping the
leading
/of the request path. An external host may itself carry a path (localhost:8000/external), which becomes a prefix of the upstream URL. - Matching is a regexp with
*appended, i.e.regexp.MatchString(path+"*", uri), not a literal string prefix.public*matchespublicitytoo. Choose path names that cannot collide, or the rewrite will produce a nonsense URL.
internal and external are not a public/private split. Every proxied
request passes through authMiddleware and needs a valid access token either
way; request.Client re-checks the principal for internal paths as a second
line of defence. The lists differ only in who wins a routing tie.
One route is not proxied. GET /health answers 200 {"status":"ok"} without a
token, and it never reaches pkg/request, so it is answered even with no
services configured. It is registered on the root router ahead of the catch-all,
which is what makes it reachable without credentials — and what makes a service
configured on the health path unreachable.
It is a liveness probe, not a readiness probe: it touches neither Redis nor Postgres nor any upstream, so "the process is up" is all it can tell you.
Cache[T Cacheable] is a two-method port, and the value being stored produces
both the key and the serialized payload — so pkg/request decides what its own
fingerprint means, and pkg/cache never needs to know about HTTP.
- Key: base64 of a JSON fingerprint of the request URL, headers, body, method
and cookies.
Dateis dropped from the header set before fingerprinting, otherwise every request would key on the second it arrived. - Value: the upstream status, status code, headers, cookies and body as JSON.
- TTL: 15 seconds, set by the adapter.
- Flow: miss (or unreadable cache) → upstream → snapshot → stream to the client → write to the cache in a goroutine.
- A miss and an outage are different errors.
redis.Nilis translated tocache.ErrNotFound, a normal outcome; a dead Redis is a real error, logged and then treated as a miss so the request still succeeds. - A corrupt entry is reported, not ignored. A payload that will not decode
fails the
Getinstead of producing an empty response.
What the cache is not: it does not look at the HTTP method, it has no invalidation API, and its TTL is not configurable.
Note the asymmetry, because it is surprising: the query string is part of the
fingerprint (the whole *url.URL is serialized into the key) but is not
forwarded upstream. So ?page=2 buys you a separate cache entry for a request
that is byte-identical to the one without it.
static/config.yaml is embedded at compile time and gitignored, so a fresh
clone will not build until you create it:
cp static/config.example.yaml static/config.yamlAny value in the file may use ${VAR} or ${VAR:-fallback}, resolved from the
process environment plus an optional .env file loaded at startup. os.ExpandEnv
is deliberately not used — it would read ${VAR:-default} as a variable named
VAR:-default and always resolve to an empty string.
| Key | Env var | Default | Notes |
|---|---|---|---|
server.host |
— | 127.0.0.1 |
:port, i.e. every interface |
server.port |
— | 8080 |
|
server.env |
ENV |
local |
Environment name. TLS is enabled if ENV is not local or test. |
server.cert_file |
TLS_CERT_FILE |
— | Path to TLS certificate (PEM) when TLS is enabled. |
server.key_file |
TLS_KEY_FILE |
— | Path to TLS private key (PEM) when TLS is enabled. |
redis.host |
— | redis |
The compose service name; use localhost when running outside compose |
redis.port |
— | 6379 |
|
db.host |
DB_HOST |
localhost |
Compose overrides this with db |
db.port |
DB_PORT |
5432 |
|
db.user |
DB_USER |
goteway |
|
db.password |
DB_PASSWORD |
goteway |
|
db.name |
DB_NAME |
goteway |
|
db.sslmode |
DB_SSLMODE |
disable |
|
db.max_conns |
— | 100 |
Pool ceiling |
db.max_idle |
— | 10 |
|
auth.access_secret |
AUTH_ACCESS_SECRET |
change-me-access |
Must differ from the refresh secret; startup fails if it does not |
auth.refresh_secret |
AUTH_REFRESH_SECRET |
change-me-refresh |
|
auth.access_ttl |
— | 15m |
|
auth.refresh_ttl |
— | 168h |
7 days |
auth.registration_token_ttl |
— | 24h |
Lifetime of make genregtoken output |
auth.issuer |
AUTH_ISSUER |
goteway |
Verified on every token |
auth.audience |
AUTH_AUDIENCE |
goteway-clients |
Verified on every token |
auth.bcrypt_cost |
— | 12 |
Cost of new password hashes |
auth.clock_skew |
— | 5s |
Leeway on expiry checks |
services.internal[].path |
— | — | Path prefix; internal routes |
services.internal[].host |
— | — | host:port, optionally with a path prefix |
services.external[].path |
— | — | Path prefix; matched first |
services.external[].host |
— | — |
There is no environment override for a bare key — ${VAR} works because it is
written into the file, so add it yourself if you need it.
Brings up the gateway, Redis and Postgres 16; the app waits for both to pass their healthchecks.
git clone git@github.com:vicent-dev/goteway.git && cd goteway
cp static/config.example.yaml static/config.yaml
$EDITOR static/config.yaml # set auth.access_secret / auth.refresh_secret
docker compose up --buildTwo things about where secrets have to go, because they are easy to get wrong:
- The YAML is embedded into the image at build time, but
${VAR}is expanded from the environment inside the container, at start-up. Exporting a variable in your shell does nothing unless compose forwards it, anddocker-compose.yamlcurrently forwards onlyDB_HOST. So either write the secrets intostatic/config.yaml(gitignored, the intended place for them) or add them to theappservice'senvironment:block. - The shipped
change-me-access/change-me-refreshpair does start: they are placeholders, not a check. They are public in this repository, so replace them with anything real — the only validated rule is that the two must differ, or the gateway refuses to boot.
The gateway listens on :8080, and DB_USER / DB_PASSWORD / DB_NAME are
read from your environment by compose to initialise Postgres. DB_HOST is set to
db inside the compose network.
Needs Go 1.27, a reachable Postgres, and ideally Redis.
cp static/config.example.yaml static/config.yaml
$EDITOR static/config.yaml # redis.host: redis -> localhost, plus your db.* and auth secrets
make install # go mod tidy
make run # go run ./cmd/server/main.go
make watch # hot reload; needs: go install github.com/cespare/reflex@latestPostgres is required: the gateway migrates and opens its pool at start-up and refuses to boot without it. Redis is not — the client is constructed without dialling, and a cache that cannot be reached is logged and treated as a miss, so the gateway runs without it, just with every request paying the full upstream cost.
server.host does not narrow the bind address (see the config table), so the
listener is reachable on every interface of the host.
Assuming admin-service is reachable at admin-service:8000 and you configured
- path: admin → host: admin-service:8000 under services.internal:
# 1. an operator mints a one-time registration token (-s silences make's echo,
# so the variable holds nothing but the token)
REG_TOKEN=$(make -s genregtoken)
# 2. the account is created and a session is started in one step
curl -sS -X POST http://localhost:8080/auth/register \
-H 'Content-Type: application/json' \
-d "{\"email\":\"ada@example.com\",\"username\":\"ada\",
\"password\":\"correct-horse-battery\",\"registration_token\":\"$REG_TOKEN\"}"
# 3. log in and keep the pair
curl -sS -X POST http://localhost:8080/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"ada@example.com","password":"correct-horse-battery"}' > session.json
# → {"access_token":"…","refresh_token":"…","token_type":"Bearer","expires_at":"…","user":{…}}
ACCESS_TOKEN=$(jq -r .access_token session.json)
REFRESH_TOKEN=$(jq -r .refresh_token session.json)
# 4. call an internal service with the access token
curl -sS http://localhost:8080/admin/users \
-H "Authorization: Bearer $ACCESS_TOKEN"
# 5. when the access token expires, rotate the refresh token — the old one dies
curl -sS -X POST http://localhost:8080/auth/refresh \
-H 'Content-Type: application/json' \
-d "{\"refresh_token\":\"$REFRESH_TOKEN\"}" > session.json
REFRESH_TOKEN=$(jq -r .refresh_token session.json)
# 6. end the session
curl -sS -X POST http://localhost:8080/auth/logout \
-H 'Content-Type: application/json' \
-d "{\"refresh_token\":\"$REFRESH_TOKEN\"}" -o /dev/null -w '%{http_code}\n' # 204The gateway answers a failure with a fixed, class-level message and keeps the cause in the log. Upstream errors carry host names and dial failures, so echoing them would leak the gateway's internals.
Sentinel (pkg/request) |
Status | Body |
|---|---|---|
ErrAccessDenied |
401 |
{"error":"unauthorized"} |
ErrServiceNotFound |
404 |
{"error":"not found"} |
ErrServiceUnavailable |
502 |
{"error":"service not available"} |
| anything else | 500 |
{"error":"internal error"} |
Sentinel (pkg/auth) |
Status | Body |
|---|---|---|
ErrInvalidInput |
400 |
the domain message, which names the offending field |
ErrEmailTaken |
409 |
auth: email already registered |
ErrUserInactive |
403 |
auth: user is not active |
ErrInvalidCredentials, ErrMissingToken, ErrInvalidToken, ErrTokenExpired, ErrTokenRevoked, ErrTokenReused, ErrRegistrationTokenInvalid, ErrRegistrationTokenExpired |
401 |
{"error":"unauthorized"} — deliberately vague |
| anything else | 500 |
{"error":"internal error"} |
Also produced by the gateway itself: 429 {"error":"rate limit reached"} from
the limiter — a response no request has ever received, see
Limitations — and 400 {"error":"invalid request body"} for
undecodable JSON on the /auth endpoints. All errors are JSON with an error
key.
Detail travels with a sentinel rather than replacing it —
fmt.Errorf("%w: email is required", ErrInvalidInput) — so callers classify with
errors.Is and still get the specifics when they want them.
make test # go test -v ./...
make test-coverage # coverage profile + browser report177 tests, and the suite needs no running services: Redis is faked with
miniredis and the GORM stores run
against in-memory SQLite, so make test is hermetic. Current coverage:
| Package | Coverage |
|---|---|
pkg/cache |
100.0% |
pkg/request |
97.6% |
pkg/repo |
94.1% |
pkg/auth |
89.1% |
app |
73.4% |
CI (.github/workflows/go.yml) runs make install,
make build and make test on every push and pull request to main, with the
Go version read from go.mod so it cannot drift.
What the gateway does not do. None of this is on the roadmap — the project is small on purpose, and these are the boundaries of what it is.
Proxying
- The caller's
Authorizationheader never reaches the upstream. Every other non-hop-by-hop request header is forwarded, but the bearer is a credential valid against this gateway and no other, so it is dropped. The gateway also has no caller identity of its own to forward in its place. - The upstream's
Content-Lengthis not forwarded. The response body is buffered, so its length is this handler's to declare. - No retries, no circuit breaker, no hedging. A failed upstream is a
502. - Responses are fully buffered, so streaming responses and WebSockets are not supported.
- TLS listener — the gateway can serve HTTPS when
ENVis notlocalortest. Configureserver.cert_fileandserver.key_filein the config (or via${TLS_CERT_FILE}/${TLS_KEY_FILE}). WhenENV=localorENV=test, it serves plain HTTP (useful for local development and tests). Terminate TLS in front of the gateway if you prefer a reverse proxy to handle it.
Routing
- Longest-prefix only, one host per path. No weighted or round-robin balancing across replicas of the same service, no regex or header-based routing, no per-route timeouts or rewrites.
- Matching is a regexp, not a literal prefix — see Routing.
ServicesConfighas noValidate(), unlike the auth config: a malformed host is discovered per request as a500rather than at startup.
Cache
- 15-second TTL, hardcoded, and responses are cached regardless of HTTP method, so non-idempotent requests can be served a stale answer.
- No invalidation API and no way to bust a key.
- A cache hit replays the snapshot — the upstream's headers, cookies, status code and body — so a cached response is byte-identical to the live one it came from.
Rate limiting
- The limiter does not actually limit anything.
rateLimiterMiddlewarecreates itsrate.Limiterinside the middleware constructor, and gorilla/mux calls that constructor again on every matched request (Router.Matchrebuilds the chain per request). Every request therefore starts from a fresh, full bucket of 10, andAllow()always succeeds — no request has ever been refused with429. The standaloneTestRateLimiterMiddleware_Throttlepasses only because it calls the built handler directly instead of going through the router. Fixing it means hoisting the limiter to server state (and probably making it per-client). - Even once wired correctly, one global bucket at 5 rps / burst 10 shared by
/auth/*and the proxy would throttle nearly all real traffic. Move it to a per-IPrate.Limiterand raise the limit before treating it as protection.
Operations
/healthis liveness only. It answers200 {"status":"ok"}with no token and without consulting Redis, Postgres or any configured service, so it reports that the process is up and nothing more — a gateway whose every upstream is down still reports healthy. There is no readiness endpoint to distinguish the two. Because it is registered on the root router ahead of the catch-all proxy, a service configured on thehealthpath becomes unreachable.- No metrics, no tracing, no structured logging. Logs are lines of the form
[GET] - /admin/users: …viapkg/log. - No dynamic configuration. The config is embedded in the binary; changing it means a rebuild and a restart.
- Redis has no authentication and no database selection — both are hardcoded.
Not built at all
No plugin system, no admin API or dashboard, no gRPC support, no OpenAPI specification, no multi-tenancy, no per-route authorization (roles exist on the user record but are not enforced).
| Path | Role |
|---|---|
cmd/server/ |
Entrypoint; wires SIGINT/SIGTERM into a graceful shutdown context |
cmd/admin/genregtoken/ |
Operator command that mints one-time registration tokens |
app/ |
Server wiring: config, Redis, Postgres, routes, middleware, error rendering |
pkg/request/ |
Client (proxy + cache orchestration) and Call (one cacheable exchange) |
pkg/cache/ |
Generic Cache[T Cacheable] interface + Redis implementation |
pkg/auth/ |
Accounts, sessions, JWT issuance/verification, bearer middleware |
pkg/repo/ |
Generic persistence port + GORM implementation |
pkg/log/ |
Logging helpers that read method and path out of the context |
static/ |
The embedded YAML config |
| Make target | Does |
|---|---|
make install |
go mod tidy |
make run |
go run ./cmd/server/main.go |
make build |
go build ./cmd/server/main.go |
make watch |
Hot reload via reflex (needs go install github.com/cespare/reflex@latest) |
make genregtoken |
Mint a one-time registration token |
make test |
go test -v ./... |
make test-coverage |
Coverage profile and HTML report |
AGENTS.md documents the conventions and the reasoning behind the error model,
including which known issues are deliberately unfixed.
Modified BSD — see LICENSE.