Production deployment
The prod stack is for a server that more than one person reaches over a network. nginx terminates TLS, asks for a password and adds the API key to each request. The browser never sees the key, and the backend has no published port. There is no hosted CodeSage service: you run this stack on your own server, with your own Algolia account.
The files are deployment/docker-compose.prod.yml, deployment/nginx.prod.conf.template and backend/Dockerfile.prod.
flowchart TB
browser(["Browser"]) --> https["nginx :443<br/>HTTPS, basic auth"]
https -- "static files" --> spa["Built dashboard"]
https -- "/api and /ws<br/>adds X-API-Key" --> backend["backend :8001<br/>one uvicorn worker"]
backend --> db[("SQLite volume")]
backend --> algolia[("Algolia")]
browser -. "plain HTTP" .-> http["nginx :80<br/>/health and a redirect"]
http -. "/health" .-> backendOn port 80, /health is proxied to the backend and everything else gets a 301 to HTTPS, so a password never crosses plain HTTP. On port 443, /health skips basic auth so load balancers and uptime checks need no login.
Dev versus prod
Section titled “Dev versus prod”| Dev | Prod | |
|---|---|---|
| Compose file | deployment/docker-compose.yml |
deployment/docker-compose.prod.yml |
| Backend image | backend/Dockerfile: source bind-mounted, uvicorn --reload |
backend/Dockerfile.prod: multi-stage, no bind mount, --workers 1, ENVIRONMENT=production so /docs is off. Installs curl and git |
| nginx | deployment/nginx.conf, plain HTTP |
deployment/nginx.prod.conf.template, HTTPS and basic auth |
| Containers | codesage_mcp_server, codesage_nginx |
codesage_mcp_server_prod, codesage_nginx_prod |
| Published ports | 127.0.0.1:80 and 127.0.0.1:8001 |
80 and 443. The backend is not published |
| Auth | The API key in the browser bundle | Basic auth in nginx (deployment/htpasswd) |
| Where the key lives | backend/.env, and the shell at build time |
The shell (CODESAGE_API_KEY), for both backend and nginx. The bundle never has it |
| Algolia index | codesage_code_entities_docker |
codesage_code_entities_prod (override with PROD_ALGOLIA_INDEX_NAME) |
| SQLite volume | backend_data |
backend_data_prod |
| Network | default | codesage_network |
The prod backend image is a two-stage build. A builder stage installs the dependencies with build-essential, gcc and git. The final stage copies only the installed packages and the app/, codesage_mcp/ and alembic/ folders, and runs as appuser (uid 1000). The git in the final image is what add_repository uses to clone repositories from hosts other than github.com.
Set it up
Section titled “Set it up”-
Create the login. nginx reads
deployment/htpasswd. The Compose file mounts it withcreate_host_path: false, so a missing file fails at start instead of becoming an empty directory.Terminal window docker run --rm httpd:alpine htpasswd -nbB <user> <password> > deployment/htpasswd # bcrypt entryThe password appears in your shell history and in the process list while this runs. Clear the history entry, or run it on a machine you trust. Add more users by appending more lines.
-
Create the certificate.
deployment/ssl/must holdcert.pemandkey.pem. For a self-signed pair:Terminal window mkdir -p deployment/sslopenssl req -x509 -newkey rsa:2048 -nodes -days 365 -subj "/CN=your-host" \-addext "subjectAltName=DNS:your-host" \-keyout deployment/ssl/key.pem -out deployment/ssl/cert.pem # use IP:1.2.3.4 for an IPBoth
deployment/htpasswdanddeployment/ssl/are gitignored. -
Set your domain. In
deployment/docker-compose.prod.yml, replace theCORS_ORIGINS=https://yourdomain.complaceholder with a comma-separated list of the origins people use, such ashttps://app.example.com,https://other.example.com. -
Export the key and any extra hosts.
Terminal window export CODESAGE_API_KEY=... # one value for backend and nginx; overrides backend/.envexport ALLOWED_HOSTS=203.0.113.5,docs.example.com # only if the site is reached by another name or IP -
Set Algolia and GitHub in
backend/.env. The prod backend readsALGOLIA_APP_ID,ALGOLIA_ADMIN_API_KEYandGITHUB_TOKENfrom that file throughenv_file. -
Build and start.
Terminal window make prod-build # docker compose -f deployment/docker-compose.prod.yml buildmake prod-up # same file, up -d; nginx waits for the backend healthcheck -
Check it.
Terminal window curl -k https://localhost/health # -k only for a self-signed certificate
The certificate
Section titled “The certificate”A self-signed certificate is fine for a test or for a private network where you can trust it on each machine. Browsers show a warning until you add it to the trust store, and the Claude Desktop bridge needs SSL_CERT_FILE (see Connect Claude Desktop).
For anything public, use a certificate from a real authority such as Let’s Encrypt:
- The stack does not run a certificate client. Get the certificate with a tool such as certbot, outside the stack.
- Port 80 in this stack redirects everything except
/health, so the usual HTTP challenge will not pass while the stack runs. Stop the stack for the challenge, or use a DNS challenge. - Copy the full chain to
deployment/ssl/cert.pemand the private key todeployment/ssl/key.pem. - nginx reads the files at start. After a renewal, copy the new files and restart nginx:
docker compose -f deployment/docker-compose.prod.yml restart nginx.
This is general guidance; the repository does not automate renewal.
Hosts and origins
Section titled “Hosts and origins”With a key set, the backend answers only to loopback hosts, the hosts named in CORS_ORIGINS, and the hosts in ALLOWED_HOSTS. Any other Host gets 403.
CORS_ORIGINSis a comma-separated list of full origins (scheme and host, and the port if it is not 443). It does two jobs: it names the browser origins that may send writes and open WebSockets, and its hostnames are added to the host allow list. A*is refused at start-up.ALLOWED_HOSTSis for names that are not inCORS_ORIGINS: a hostname or IP, without a port.*.example.comallows subdomains.*allows any host and logs a warning, because it turns off DNS-rebinding protection.- A site reached by another name or by IP. Add that hostname or IP to
ALLOWED_HOSTS. Browser writes and WebSocket connections are accepted from the API’s own origin or an origin inCORS_ORIGINS, so a page served from that name works once the host is allowed. - Why nginx keeps the port. On
/apiand/wsnginx sendsHost $http_host, so a non-443 port stays in the header and matches the browser’sOrigin.
What nginx adds
Section titled “What nginx adds”- Response headers.
Strict-Transport-Security(max-age=63072000, two years),X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Content-Security-Policy: frame-ancestors 'none'andReferrer-Policy: same-origin. TLS is 1.2 and 1.3 only, with a 10 MB shared session cache. - The key. nginx sets
X-API-Keyitself on/apiand/ws, and setsAuthorizationto empty, so the backend never sees the login. Only${CODESAGE_API_KEY}is substituted in the template (NGINX_ENVSUBST_FILTER). nginx’s own$variablesare left alone. - Forwarded headers, overwritten and not appended.
X-Forwarded-Foris set to the real client address andX-Forwarded-Prototohttps. The backend uses the first for per-client rate limits and WebSocket caps, so a client must not be able to choose its own address. - uvicorn trust.
Dockerfile.prodstarts uvicorn with--forwarded-allow-ips "*". That is safe only because the backend has no published port and nginx is the only peer that can reach it. Keep it that way. - Limits.
client_max_body_sizeis 2 MB./wsallows idle connections for an hour./api/times out after 60 s. - Compression. Text, CSS, JSON and JavaScript are gzipped.
- Static files. The dashboard is served from the nginx image, with
index.htmlas the fallback for any path.
Health behaviour
Section titled “Health behaviour”GET /health returns:
| Result | HTTP status | Meaning |
|---|---|---|
healthy |
200 | The database answers and Algolia is reachable |
degraded |
200 | Algolia is unreachable or not configured |
unhealthy |
503 | The database check failed |
degraded is still 200, so the container stays healthy while Algolia is unset. The Docker health check runs curl -f http://localhost:8001/health every 30 s with a 10 s timeout and 3 retries, and nginx has depends_on with service_healthy. So nginx does not start until the backend reports healthy, which can take up to about half a minute after the backend starts.
Connect Claude Desktop
Section titled “Connect Claude Desktop”make install-claude ARGS="--server-url https://your-host --basic-auth <user>" # asks for the password- Password. The installer stores it in the macOS Keychain (service
codesage-mcp). The Claude Desktop config holds only the user name. - No key needed. nginx adds the API key, so the bridge sends none. The installer does not copy the backend’s key when you pass
--basic-auth. - Plain HTTP is refused. The bridge will not send the login or a key over
http://to a host other than loopback unlessCODESAGE_ALLOW_INSECURE_AUTH=1is set in itsenv. - Self-signed certificate. Add
SSL_CERT_FILE=/path/to/cert.pemto the bridge’senvin the Claude Desktop config. - Without the Keychain. Set
CODESAGE_BASIC_AUTH_PASSWORDin thatenvblock instead. It is stored in plain text, in a file the installer keeps at mode 600. - Rate limit. Tool calls are limited to 30 per minute per client address. Behind nginx the address is the real client’s, not nginx’s.
--basic-auth needs the macOS security command. See Claude Desktop for the full flow.
Operate it
Section titled “Operate it”docker compose -f deployment/docker-compose.prod.yml logs -f backend # backend logsdocker compose -f deployment/docker-compose.prod.yml logs -f nginx # access and error logsdocker compose -f deployment/docker-compose.prod.yml ps # container and health statedocker compose -f deployment/docker-compose.prod.yml down # stop prod; data is keptcurl -k https://localhost/health # prod health- Only two make targets.
make prod-buildandmake prod-upare the prod targets. Every other Docker target uses the dev file, so call Compose directly for prod. - Data. The SQLite file is in the named volume
backend_data_prod, which survivesdown. To wipe it, rundown -vwith the prod file. That does not clean Algolia, so delete repositories in the dashboard first, or remove the index in Algolia. - Upgrades. Pull the new code, then
make prod-buildandmake prod-up. Migrations run at start-up. If a run was in progress, the restart marks itfailed, and you reprocess it. - Do not run dev and prod together. Both publish port 80.
- One worker. Do not change
--workers 1. See Known limitations.
Pre-flight checklist
Section titled “Pre-flight checklist”deployment/htpasswdexists and has at least one user.deployment/ssl/cert.pemandkey.pemexist and match, and the certificate covers your hostname or IP.CORS_ORIGINSin the Compose file lists the exact origins people use, withhttps://.CODESAGE_API_KEYis exported in the shell that runsmake prod-up.ALLOWED_HOSTSis exported if the site is reached by an IP or a second name.backend/.envhasALGOLIA_APP_IDandALGOLIA_ADMIN_API_KEY, andGITHUB_TOKENif you need private repositories or higher limits.- Ports 80 and 443 are free on the host, and the dev stack is down.
curl -k https://your-host/healthreturns200.- You signed in to the dashboard, added a small repository, and watched it reach
completed. - You ran a search from the dashboard, and from Claude Desktop if you use it.
When something goes wrong
Section titled “When something goes wrong”| Symptom | Fix |
|---|---|
make prod-up fails on a bind mount |
deployment/htpasswd or deployment/ssl/ is missing. Create them as above |
| nginx exits with an SSL error | cert.pem or key.pem is missing or does not match. Regenerate the pair and read logs nginx |
403 from the API |
The Host is not loopback, in CORS_ORIGINS or in ALLOWED_HOSTS. Add it to ALLOWED_HOSTS and restart |
Writes or the WebSocket get 403 from a browser |
The origin is neither the API’s own nor in CORS_ORIGINS. Add the full origin to the Compose file and restart |
| Backend unhealthy, nginx never starts | Usually the missing key. Read the backend logs |
More in Troubleshooting. The reasoning behind each control is in the Security model.