The honey badger of HTTP proxies. Takes your request, throws it in a Redis-backed job queue, and deals with it when it damn well pleases. You get a job ID back instantly — come back later to pick up the goods.
Think of it as "I'll get back to you" as a service. Every HTTP request becomes an async job. No more hanging connections, no more timeouts, no more "please hold." Fire and forget, poll when ready.
Oh, and it caches too. Because hitting the same endpoint twice is for people who enjoy watching paint dry.
- How it works
- Quick start
- Configuration
- API
- Direct proxy bypass
- Routing
- Headers
- Client libraries
- Use cases
- Architecture
- Development
- License
Client proxq Redis Upstream
| | | |
|-- POST /foo --> | | |
|<- 202 {jobId} - | | |
| |-- enqueue ----> | |
| (go touch | | |
| grass) | | <- worker --- |
| | | wakes up |
| | | -----------> (request)
| | | <----------- (response)
| | | |
|-- GET /{jobId}->| | |
|<- {status} ---- | | |
| | | |
|-- GET /content->| | |
|<- {response} -- | | |
| | | |
|-- PUT /big ---> | --------- direct proxy ------> |
|<- {response} -- | <----------------------------- |
Most requests go through the meat grinder:
- Accept — proxq takes your HTTP request (any method, any path, any body).
- Route — matches the request path to an upstream via longest-prefix match.
- Queue — serializes the whole thing (method, URL, headers, body) into Redis via asynq and hands you a job ID immediately.
- Forward — a worker picks it up and fires it at upstream with all your original headers, body, and proxy headers (
X-Forwarded-For,X-Real-IP,X-Forwarded-Proto). Hop-by-hop headers get stripped per RFC 7230 because we're civilized like that. - Store — the upstream response (status code, headers, body) gets saved as the job result.
- Poll — you come back whenever you want and grab the result.
Large uploads, chunked transfers, and WebSocket connections bypass the queue entirely and get proxied straight to upstream — no buffering, no double transfer, no memory bomb.
services:
proxq:
image: psyb0t/proxq
ports:
- "8080:8080"
environment:
PROXQ_CONFIG: /etc/proxq/config.yaml
configs:
- source: proxq_config
target: /etc/proxq/config.yaml
depends_on:
- redis
redis:
image: redis:7-alpine
restart: unless-stopped
configs:
proxq_config:
content: |
listenAddress: "0.0.0.0:8080"
redis:
addr: "redis:6379"
upstreams:
- prefix: "/"
url: "http://your-api:3000"That's it. Your API is now async. You're welcome.
Everything lives in a YAML config file. See config.yaml.example for the full reference.
Config file path is resolved in order: --config flag → PROXQ_CONFIG env var → config.yaml in the current directory.
| Field | Type | Default | Description |
|---|---|---|---|
listenAddress |
string | 127.0.0.1:8080 |
HTTP server bind address |
redis.addr |
string | 127.0.0.1:6379 |
Redis server address |
redis.password |
string | "" |
Redis password |
redis.db |
int | 0 |
Redis database number |
queue |
string | default |
asynq queue name |
concurrency |
int | 10 |
How many workers hammer upstream simultaneously |
jobsPath |
string | /__jobs |
Base path for the jobs API endpoints |
taskRetention |
duration | 1h |
How long completed/failed jobs stick around in Redis |
Duration values use Go syntax: 30s, 5m, 1h, 1h30m.
Multiple upstreams with path-prefix routing. Longest prefix wins. The prefix is stripped before forwarding.
| Field | Type | Default | Description |
|---|---|---|---|
prefix |
string | required | URL path prefix for routing. Stripped before forwarding. |
url |
string | required | Upstream server URL. Can include a path (e.g., http://api:3000/v2). |
timeout |
duration | 5m |
Per-upstream request timeout |
maxRetries |
int | 0 |
Retry attempts on failure. 0 = no retries. |
retryDelay |
duration | 0 |
Fixed delay between retries. 0 = exponential backoff. |
maxBodySize |
int | 10485760 |
Max request body to queue (bytes). 10 MB default. |
directProxyThreshold |
int | 10485760 |
Body size above which requests bypass the queue. 0 disables. |
directProxyMode |
string | proxy |
How bypassed requests reach upstream: proxy or redirect (307). |
cacheKeyExcludeHeaders |
list | [] |
Headers to exclude from cache key. Empty = defaults (see Caching). |
pathFilter |
object | See Path filter. |
upstreams:
- prefix: "/api"
url: "http://api-server:3000"
timeout: "5m"
maxRetries: 3
retryDelay: "10s"
pathFilter:
mode: "blacklist"
patterns:
- "^/api/health"
- prefix: "/files"
url: "http://file-server:9000/storage"
timeout: "10m"
maxBodySize: 1073741824
directProxyThreshold: 0Per-upstream regex-based filtering. Controls which requests get queued vs direct-proxied.
| Field | Type | Default | Description |
|---|---|---|---|
pathFilter.mode |
string | blacklist |
blacklist: matching paths bypass the queue. whitelist: only matching paths get queued. |
pathFilter.patterns |
list | [] |
Regex patterns matched against the request path. |
Blacklist (default) — matching paths skip the queue:
pathFilter:
mode: "blacklist"
patterns:
- "^/api/auth"
- "^/api/health"Auth and health go straight to upstream. Everything else gets queued.
Whitelist — only matching paths get queued:
pathFilter:
mode: "whitelist"
patterns:
- "^/api/reports"
- "^/api/exports"Reports and exports get queued. Everything else goes straight through.
Failed jobs can be retried automatically. "Failed" means the transport itself broke — network error, timeout, connection refused. An upstream returning 500 is still a completed job (the 500 response gets stored as the result, because that's what upstream said).
| Field | Type | Default | Description |
|---|---|---|---|
maxRetries |
int | 0 |
Retry attempts. 0 = no retries. |
retryDelay |
duration | 0 |
Fixed delay. 0 = exponential backoff (n^4 seconds). |
Exponential backoff schedule:
| Attempt | Delay |
|---|---|
| 1st | 1 second |
| 2nd | 16 seconds |
| 3rd | 81 seconds |
| 4th | ~4 minutes |
| 5th | ~10 minutes |
Or just set a fixed delay:
upstreams:
- prefix: "/"
url: "http://flaky-backend:8080"
maxRetries: 5
retryDelay: "30s"| Field | Type | Default | Description |
|---|---|---|---|
cache.mode |
string | none |
none, memory (in-memory LRU), or redis (shared). |
cache.ttl |
duration | 5m |
How long cached responses stay fresh |
cache.maxEntries |
int | 10000 |
Max entries for in-memory LRU |
cache.redisKeyPrefix |
string | proxq: |
Key prefix for Redis cache, so it doesn't collide with job data |
cache:
mode: "redis"
ttl: "10m"
redisKeyPrefix: "proxq:"When mode: redis, cache uses the same Redis instance as the job queue.
Cache rules:
- Any method gets cached. Same POST with the same body? Cache hit. Different body? Cache miss.
- Only 2xx responses get cached. Your 500s aren't worth remembering.
- Cache key =
sha256(method + url + headers + body). Volatile headers are excluded from the key so they don't bust the cache. - Cached responses include
X-Cache-Status: HIT. Fresh upstream responses includeX-Cache-Status: MISS.
By default, these headers are excluded from cache keys: X-Request-ID, X-Forwarded-For, X-Real-IP, X-Forwarded-Proto. Override per upstream with cacheKeyExcludeHeaders:
upstreams:
- prefix: "/api"
url: "http://backend:3000"
cacheKeyExcludeHeaders:
- "X-Request-ID"
- "Authorization"
- "X-Trace-ID"When cacheKeyExcludeHeaders is set, it replaces the defaults entirely — only the headers you list are excluded.
All job endpoints live under jobsPath (default /__jobs). Every response that proxq generates (not proxied from upstream) carries the X-Proxq-Source: proxq header — that's how you tell proxq responses from upstream responses.
Any request that doesn't hit a job endpoint gets intercepted, routed to an upstream, and queued — unless it triggers a direct proxy bypass.
POST /api/heavy-computation HTTP/1.1
Content-Type: application/json
Authorization: Bearer token
{"data": "lots of it"}HTTP/1.1 202 Accepted
Content-Type: application/json
X-Proxq-Source: proxq
{"jobId": "550e8400-e29b-41d4-a716-446655440000"}If no upstream matches the request path: 502 Bad Gateway with X-Proxq-Source: proxq.
GET /__jobs/550e8400-e29b-41d4-a716-446655440000 HTTP/1.1HTTP/1.1 200 OK
Content-Type: application/json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "completed",
"completedAt": "2025-01-01T00:00:00Z"
}Failed job:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "failed",
"error": "forward request: dial tcp: connection refused"
}Not found: 404 Not Found with X-Proxq-Source: proxq.
Status lifecycle:
| Status | What it means | Underlying asynq states |
|---|---|---|
queued |
Waiting to be picked up | pending, scheduled, aggregating |
running |
Worker is on it, or waiting for a retry | active, retry |
completed |
Done. Response stored. Even if upstream returned 4xx/5xx. | completed |
failed |
Transport broke after all retries exhausted. | archived |
This is the payoff. Replays the upstream response exactly — status code, headers, body. As if you'd called upstream directly.
GET /__jobs/550e8400-e29b-41d4-a716-446655440000/content HTTP/1.1HTTP/1.1 200 OK
Content-Type: application/json
X-Custom-Header: from-upstream
{"result": "done"}If the upstream returned 404, you get 404 back — but without X-Proxq-Source (because it came from upstream, not proxq).
If the job isn't done yet or doesn't exist: 404 Not Found with X-Proxq-Source: proxq.
How to tell the difference:
X-Proxq-Source: proxqpresent → proxq says "job not ready or doesn't exist"X-Proxq-Sourceabsent → that's the actual upstream response
DELETE /__jobs/550e8400-e29b-41d4-a716-446655440000 HTTP/1.1HTTP/1.1 200 OK
Content-Type: application/json
{"status": "cancelled"}Not found: 404 Not Found with X-Proxq-Source: proxq.
Not everything needs the queue. These requests skip it entirely:
| Condition | Why | Checked |
|---|---|---|
WebSocket (Connection: upgrade + Upgrade: websocket) |
Persistent bidirectional. Can't queue that. | First |
Path filter match (per-upstream pathFilter) |
You said so. | Second |
Chunked transfer (Transfer-Encoding: chunked) |
Size unknown, could be huge. | Third |
Large body (Content-Length > directProxyThreshold) |
No point buffering a 1 GB upload into Redis. | Fourth |
How bypassed requests reach upstream:
directProxyMode: proxy(default) — reverse-proxied through proxq. Client never sees the upstream URL. Streams in both directions.directProxyMode: redirect— proxq responds with307 Temporary Redirectto the upstream URL. Client must be able to reach upstream directly.
Upstreams are sorted by prefix length (longest first). First match wins.
A prefix matches when the request path equals the prefix exactly, or starts with the prefix followed by /. This prevents /api from accidentally matching /api2.
| Request path | /api |
/api/v2 |
/ |
|---|---|---|---|
/api/users |
match | no | match |
/api/v2/users |
match | match (wins) | match |
/api2/data |
no | no | match |
/other |
no | no | match |
The matched prefix is stripped from the request path before forwarding. Query strings are preserved.
| Request | Prefix | Forwarded path |
|---|---|---|
GET /api/users?page=1 |
/api |
GET /users?page=1 |
GET /api |
/api |
GET / |
POST /uploads/img.png |
/uploads |
POST /img.png |
GET /anything |
/ |
GET /anything |
The upstream URL can include a path. The stripped request path gets appended to it.
upstreams:
- prefix: "/files"
url: "http://storage:9000/bucket/data"| Request | Upstream receives |
|---|---|
GET /files/img.png |
GET http://storage:9000/bucket/data/img.png |
GET /files |
GET http://storage:9000/bucket/data/ |
proxq validates your config at startup and refuses to run if something's wrong:
- At least one upstream is required.
- Each upstream needs both
prefixandurl. - Single upstream:
prefix: "/"is fine (catch-all). - Multiple upstreams:
prefix: "/"is not allowed — too ambiguous. - No nested prefixes:
/apiand/api/v2together is an error. - No prefix can conflict with
jobsPath:/__jobsas a prefix whenjobsPathis/__jobsis an error. - Path filter patterns must be valid regexes.
| Header | Value | When |
|---|---|---|
X-Proxq-Source |
proxq |
Every response proxq generates: 202 accepted, 502 no match, 500 errors, 307 redirects, 404 from job endpoints, reverse proxy errors. Never on responses proxied from upstream. |
X-Cache-Status |
HIT / MISS |
On cached responses when caching is enabled. |
| Header | Description |
|---|---|
X-Forwarded-For |
Original client IP |
X-Real-IP |
Original client IP (alternate) |
X-Forwarded-Proto |
Original request scheme (http or https) |
All original request headers are preserved. Hop-by-hop headers (Connection, Keep-Alive, Proxy-Authenticate, Proxy-Authorization, TE, Trailers, Transfer-Encoding, Upgrade) are stripped per RFC 7230.
Drop-in replacement for openai-go. Swap one line and all your SDK calls go through proxq transparently — chat completions, embeddings, images, audio, everything.
import proxqopenai "github.com/psyb0t/docker-proxq/pkg/clients/openai"
// Before: client := openai.NewClient(option.WithAPIKey("sk-..."))
// After:
client := proxqopenai.NewClient(proxqopenai.Config{
ProxqBaseURL: "https://proxq.example.com",
APIKey: "sk-...",
})
// Same code, same types, same return values
resp, err := client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{
Model: openai.ChatModelGPT4o,
Messages: []openai.ChatCompletionMessageParamUnion{
openai.UserMessage("hello"),
},
})
fmt.Println(resp.Choices[0].Message.Content)The client injects a custom http.RoundTripper into the SDK. Non-streaming requests get enqueued, polled, and returned as if you called OpenAI directly. Streaming and direct-proxied responses pass through as-is. Your HTTPClient settings (TLS config, timeouts, cookie jar) are fully preserved.
See pkg/clients/openai/README.md for the full docs.
Your CDN or reverse proxy (nginx, Cloudflare, etc.) has a request timeout. Your backend sometimes takes longer. Stick proxq between them:
upstreams:
- prefix: "/"
url: "http://slow-backend:8080"
timeout: "10m"
maxRetries: 2Client sends request → gets a job ID back instantly (reverse proxy is happy, fast response) → worker takes as long as the backend needs → client polls for the result whenever.
Fire webhooks without blocking the sender. Queue them, deliver at your own pace, retry if needed:
upstreams:
- prefix: "/hooks"
url: "http://webhook-processor:8080"
timeout: "30s"
maxRetries: 5
retryDelay: "10s"Some endpoints are fast (auth, health), others are slow (reports, exports). Queue the slow ones, let the fast ones pass through:
upstreams:
- prefix: "/api"
url: "http://backend:3000"
timeout: "5m"
pathFilter:
mode: "blacklist"
patterns:
- "^/api/auth"
- "^/api/health"Auth and health requests bypass the queue and hit the backend directly. Everything else gets queued.
Accept large uploads, queue them for processing, let the client check back later:
upstreams:
- prefix: "/process"
url: "http://media-worker:9000"
timeout: "30m"
maxBodySize: 1073741824
directProxyThreshold: 0directProxyThreshold: 0 disables body-size bypass — everything gets queued regardless of size (up to maxBodySize).
Route to different backends by path, each with its own rules:
upstreams:
- prefix: "/api"
url: "http://api:3000"
timeout: "5m"
maxRetries: 3
- prefix: "/ml"
url: "http://ml-service:8080"
timeout: "15m"
- prefix: "/uploads"
url: "http://file-server:9000/storage"
timeout: "10m"
maxBodySize: 1073741824
directProxyMode: "redirect"docker-proxq/
├── cmd/ # the main() nobody reads
├── internal/
│ ├── app/ # wiring: asynq + HTTP server + cache
│ ├── config/ # YAML config parsing, validation, defaults
│ ├── proxy/ # handler, worker, job types, jobs API
│ └── testinfra/ # testcontainers helpers
├── pkg/
│ ├── types/ # public constants (headers)
│ └── clients/openai/ # drop-in OpenAI SDK client
├── tests/ # e2e tests (Docker-based)
├── config.yaml.example # full config reference
├── Dockerfile
└── Makefile
Built on:
- asynq — Redis-backed task queue (the job management layer)
- aichteeteapee — HTTP forwarding engine (the proxy guts, header stripping, caching)
- common-go — cache package (in-memory LRU + Redis implementations)
make dep # vendor dependencies
make lint # golangci-lint with all the annoying linters enabled
make test # unit + integration tests (race detector on)
make test-coverage # tests with 90% coverage threshold
# e2e tests — spins up Redis + upstream + proxq
# via testcontainers. No manual setup needed.
cd tests && go test -v -timeout 10m ./...
make build # docker buildDo whatever you want. If it breaks, you get to keep both pieces.