Quickstart
Deploy the supported Docker Compose stack, wire up a provider and model, issue an API key, and make your first request.
This guide takes you from nothing to a working request against a self-hosted Cordy Gateway. It uses the supported deployment path — Docker Compose — and ends with a real chat completion via curl, the OpenAI Python SDK, and the OpenAI JavaScript SDK.
Plan for about 15–20 minutes. You will:
- Check requirements.
- Configure secrets in a
.envfile. - Bring up the production stack with Docker Compose.
- Create an admin user.
- Register a provider, channel, and model in the Admin.
- Issue a
wnx_API key. - Make your first request.
- Verify health.
1. Requirements
- Docker Engine 24+ and the Docker Compose v2 plugin (
docker compose, not the legacydocker-compose). - The Cordy Gateway distribution (the repository or delivery bundle) on the host, so that
docker-compose.prod.ymland thedeploy/anddocker/directories are present. - A host with at least 4 GB RAM free for the full stack (Postgres, Redis, PgBouncer, Admin, Gateway, worker, nginx).
- For a real production deployment: a domain name and TLS certificate. The bundled nginx terminates plain HTTP; put a
443server block and certs indocker/nginx/, or front the stack with your own reverse proxy. For a local trial you can skip TLS (see the note in step 2).
Verify Docker is ready:
docker --version
docker compose version
2. Configure secrets
Copy the template and fill in every placeholder:
cp deploy/env.template .env
Generate the three secrets you must set:
# Django secret key
python -c "import secrets; print(secrets.token_urlsafe(50))"
# Channel credential encryption key (Fernet)
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# A strong database password — any long random string
python -c "import secrets; print(secrets.token_urlsafe(24))"
Set at least these required variables in .env:
| Variable | Purpose | Notes |
|---|---|---|
DB_NAME | Postgres database name | e.g. cordy |
DB_USER | Postgres user | |
DB_PASSWORD | Postgres password | Use the generated value |
DJANGO_SECRET_KEY | Django cryptographic key | Use the generated value |
DJANGO_ALLOWED_HOSTS | Hostnames the Admin serves | Comma-separated; add localhost,127.0.0.1 for a local trial |
DJANGO_ADMIN_URL | Admin URL path prefix | Must end with /; pick a non-obvious value, e.g. ops-7f3a/ |
CHANNEL_ENCRYPTION_KEYS | Fernet key(s) for upstream credentials | Must be identical for the Admin and Gateway services |
CORDY_GATEWAY_BASE_URL | Client-facing API base URL shown in the member portal | Use https://gateway.example.com/v1 in production or http://127.0.0.1:8002/v1 for a local trial |
Local-trial note: the bundled nginx serves plain HTTP. For a no-TLS local test only, set
DJANGO_SECURE_SSL_REDIRECT=Falsein.envso the Admin does not force an HTTPS redirect. Leave itTrue(the default) for any real deployment.
CHANNEL_ENCRYPTION_KEYS is mandatory in production: the Gateway and Admin both refuse to start without it, and both must use the same value or channel credentials cannot be decrypted. Keep .env out of version control and store secrets in your secrets manager.
The base docker-compose.prod.yml does not inject arbitrary .env values into gateway; --env-file only supplies Compose interpolation. Create docker-compose.production-overrides.yml beside it so this deployment actually runs in production mode, receives the documented security/runtime settings, and stores the credit-consume fallback on a named volume:
services:
admin:
environment:
CORDY_GATEWAY_BASE_URL: ${CORDY_GATEWAY_BASE_URL:?set CORDY_GATEWAY_BASE_URL}
ADMIN_LANGFUSE_TRACE_URL_TEMPLATE: ${ADMIN_LANGFUSE_TRACE_URL_TEMPLATE:-}
gateway:
environment:
ENVIRONMENT: production
CLIENT_CORS_ORIGINS: ${CLIENT_CORS_ORIGINS:-}
CLIENT_CORS_ALLOW_WILDCARD_IN_PRODUCTION: ${CLIENT_CORS_ALLOW_WILDCARD_IN_PRODUCTION:-false}
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-}
GATEWAY_METRICS_TOKEN: ${GATEWAY_METRICS_TOKEN:-}
GATEWAY_METRICS_ALLOWED_CIDRS: ${GATEWAY_METRICS_ALLOWED_CIDRS:-}
GATEWAY_DEFAULT_ROUTING_STRATEGY: ${GATEWAY_DEFAULT_ROUTING_STRATEGY:-weighted}
GATEWAY_MAX_BODY_BYTES: ${GATEWAY_MAX_BODY_BYTES:-10485760}
GATEWAY_MAX_INFLIGHT_PER_KEY: ${GATEWAY_MAX_INFLIGHT_PER_KEY:-50}
GATEWAY_DEFAULT_MAX_COMPLETION_TOKENS: ${GATEWAY_DEFAULT_MAX_COMPLETION_TOKENS:-4096}
GATEWAY_PRICE_TTL_SECONDS: ${GATEWAY_PRICE_TTL_SECONDS:-300}
IDEMPOTENCY_TTL_SECONDS: ${IDEMPOTENCY_TTL_SECONDS:-600}
GATEWAY_CREDIT_CONSUME_WAL: /var/lib/cordy/credit-consume.jsonl
OTEL_TRACING_ENABLED: ${OTEL_TRACING_ENABLED:-false}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-cordy-gateway}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-}
OTEL_SHUTDOWN_TIMEOUT_SECONDS: ${OTEL_SHUTDOWN_TIMEOUT_SECONDS:-2.0}
volumes:
- gateway_wal:/var/lib/cordy
volumes:
gateway_wal:
With an empty CLIENT_CORS_ORIGINS, production mode denies cross-origin browser access. For a production endpoint behind public nginx, set GATEWAY_METRICS_TOKEN and leave GATEWAY_METRICS_ALLOWED_CIDRS empty; allowing the nginx peer would admit every public request it proxies. See the Configuration reference for proxy, CORS, metrics, WAL, tracing, and custom-UID details.
3. Bring up the stack
docker compose \
-f docker-compose.prod.yml \
-f docker-compose.production-overrides.yml \
--env-file .env up -d --build
This starts the full production topology:
| Service | Role | Port |
|---|---|---|
nginx | Load balancer / TLS termination point | Publishes GATEWAY_HTTP_PORT (default 8002) |
gateway | Data plane (Starlette + Granian, OpenAI-compatible API) | Internal 8002, fronted by nginx |
admin | Control plane (Django Admin) | Publishes ADMIN_HTTP_PORT (default 8001) |
postgres | PostgreSQL 18 with pg_cron | Internal only |
pgbouncer | Transaction connection pooler for the gateway hot path | Internal only |
redis | Redis 8 — rate limits, quotas, caching, buffers | Internal only |
worker | Background worker (usage settlement, audit upload) | Internal only |
The Admin container automatically runs database migrations and collects static files before starting. Postgres, PgBouncer, and Redis are internal-only and are not published to the host.
Watch startup and confirm the services are healthy:
docker compose -f docker-compose.prod.yml -f docker-compose.production-overrides.yml --env-file .env ps
docker compose -f docker-compose.prod.yml -f docker-compose.production-overrides.yml --env-file .env logs -f admin
4. Create an admin user
Create a Django superuser to sign in to the Admin:
docker compose -f docker-compose.prod.yml -f docker-compose.production-overrides.yml --env-file .env exec admin \
uv run python manage.py createsuperuser
Follow the prompts for email and password. Then sign in at:
http://<host>:8001/<DJANGO_ADMIN_URL>
For example, if DJANGO_ADMIN_URL=ops-7f3a/, the login URL is http://<host>:8001/ops-7f3a/.
5. Register a provider, channel, and model
Cordy Gateway resolves the model a client requests through a chain of records. You create them once in the Admin under Gateway Configuration. The minimum wiring to serve one model:
- Provider — the upstream vendor integration (for example, OpenAI). Set
name,provider_type(e.g.OPENAI), and statusACTIVE. - Public model — the public identifier clients will request in the
modelfield (for example, a sluggpt-4o-mini). This is the only model name your clients ever see. - Provider model — links the Provider and the Public model to the concrete upstream model name (for example,
gpt-4o-mini), with capability flags (supports_chat,supports_embeddings,supports_streaming). Add a Price history row with an effective start time and per-1,000-token prices for metering. - Channel — a routing target on the Provider. Set its upstream credential (the API key—stored Fernet-encrypted),
weight,priority, and optionalregion. Leaveendpoint_urlblank for a standard built-in provider endpoint; the current form does not connect that field to the data-plane custom endpoint input. See Providers. - Channel provider model — binds the Channel to the Provider model, enabling that model to be served through that channel. The provider model must belong to the channel's provider.
Once at least one active channel can serve your public model, the model becomes routable. See Providers for a full walkthrough and Concepts for how the chain resolves at request time.
6. Issue an API key
In the Admin, create (or pick) a user, then create an API key for that user. On creation, the gateway generates a wnx_-prefixed key and shows the full plaintext exactly once — copy it immediately; only its SHA-256 hash is stored.
When creating the key, set:
- Scopes — the endpoint families the key may call (
chat,completions,embeddings,models,balance,admin). New keys default tochat,completions,embeddings, andmodels; add only what the client needs. An empty scope list is legacy unrestricted behavior. - Quota — monthly token allowance.
- Rate limit — requests per minute.
Before the first billable request, select the user in the Admin user list, enter a positive Top-up USD, and run Top up prepaid credit. A new user starts with zero credit; without a top-up the gateway returns 402 insufficient_credits. See Admin console for user defaults and the separate token quota.
Export the key for the next step:
export CORDY_API_KEY="wnx_...your key..."
export CORDY_BASE_URL="http://<host>:8002/v1" # use https://... in production
7. Make your first request
curl
curl -sS "$CORDY_BASE_URL/chat/completions" \
-H "Authorization: Bearer $CORDY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4o-mini",
"messages": [
{"role": "user", "content": "Say hello from Cordy Gateway in one sentence."}
]
}'
Replace gpt-4o-mini with the public model slug you created. A successful response is a standard OpenAI chat completion object.
Python (OpenAI SDK)
pip install openai
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ["CORDY_API_KEY"],
base_url=os.environ["CORDY_BASE_URL"], # http://<host>:8002/v1
)
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "user", "content": "Say hello from Cordy Gateway in one sentence."}
],
)
print(resp.choices[0].message.content)
JavaScript / TypeScript (OpenAI SDK)
npm install openai
import OpenAI from "openai";
const client = new OpenAI({
apiKey: process.env.CORDY_API_KEY,
baseURL: process.env.CORDY_BASE_URL, // http://<host>:8002/v1
});
const resp = await client.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "user", content: "Say hello from Cordy Gateway in one sentence." },
],
});
console.log(resp.choices[0].message.content);
8. Verify health
The health endpoints are public (no API key required):
# Liveness — is the process up?
curl -sS http://<host>:8002/health
# Readiness — can it actually serve chat/embeddings right now?
curl -sS http://<host>:8002/health/ready
# Per-provider health breakdown
curl -sS http://<host>:8002/health/providers
/health returns 200 as soon as the process is running. /health/ready and /health/detailed return 503 until at least one channel can serve traffic — this is expected before you finish the wiring in step 5, and turns to 200 once a model is routable.
Troubleshooting
- Every request returns
401right after deploy. The gateway data plane builds its database DSN fromDB_NAME/DB_USER/DB_PASSWORDdirectly (it does not parseDATABASE_URL). If these are missing or wrong on thegatewayservice, it cannot reach the database and auth fails for every request. Confirm the gateway'sDB_*values match the admin's. 401 invalid_api_key. The key is wrong or was never activated. Re-issue the key in the Admin and copy the one-time plaintext exactly, including thewnx_prefix.404 model_not_foundor an empty/v1/modelslist. The model chain is incomplete or inactive. Confirm the Public model, Provider model, Channel, and Channel-provider-model records all exist and areACTIVE, and that the channel has a valid upstream credential./health/readystays503. No channel can serve traffic yet. Finish the provider/channel/model wiring, then check/health/providersfor the per-provider status and theadminandgatewaylogs.- Admin login redirects to HTTPS on a local no-TLS trial. Set
DJANGO_SECURE_SSL_REDIRECT=Falsein.envand recreate theadminservice. Never do this for a real deployment. CHANNEL_ENCRYPTION_KEYSerrors at startup. The variable is unset, or the Admin and Gateway have different values. Set the same Fernet key(s) for both services.- Provider calls fail with an upstream auth error. The channel's stored credential is wrong. Edit the channel in the Admin and re-enter the upstream API key.
Next steps
- Client integration — streaming, tool calling, embeddings, and product headers.
- API reference — every endpoint, scope, and error code.
- Admin console — members, keys, credit, quotas, pricing, and monitoring.
- Member portal — self-service keys, balance, and usage.
- Operations — health, diagnosis, backups, upgrades, and recovery.
- Deployment — scaling, observability, backups, and upgrades.
- Security — the trust model and hardening checklist.