Files
AegisGitea-MCP/docs/deployment.md
T

8.3 KiB

Deployment

This page covers running the HTTP server for remote clients. For a local stdio setup see getting-started.md. Read hardening.md as well: this server hands the power of its Gitea token to every client that can authenticate.

Choose a mode

  • Admin-token only: set GITEA_TOKEN and MCP_BEARER_TOKEN. Every client with the bearer token acts as the token owner.
  • Per-user OAuth: additionally set OAUTH_ENABLED=true with the OAuth settings. Users sign in with their own Gitea account (after a consent step on /oauth/consent) and act under their own permissions. The static bearer keeps working next to it as the admin identity; leave MCP_BEARER_TOKEN unset to turn that off.
  • OAuth only: OAUTH_ENABLED=true with neither MCP_BEARER_TOKEN nor GITEA_TOKEN. The server starts and serves signed-in users only; admin-token mode does not exist in this deployment, so no request can ever fall back to an admin identity. Recommended for public deployments.

Docker

Image: git.hiddenden.cafe/hiddenden/aegisgitea-mcp. Tag dev is built from the dev branch and latest from main. uv run aegis-gitea-mcp setup prints this compose service, with your port:

services:
  aegis-gitea-mcp:
    image: git.hiddenden.cafe/hiddenden/aegisgitea-mcp:dev
    restart: unless-stopped
    env_file: .env
    environment:
      MCP_TRANSPORT: http
      MCP_HOST: 0.0.0.0  # allowed because authentication is configured
      DATA_DIR: /var/lib/aegis-mcp
    ports:
      - "127.0.0.1:8080:8080"
    volumes:
      - aegis-mcp-data:/var/lib/aegis-mcp
    read_only: true
    tmpfs:
      - /tmp
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true

volumes:
  aegis-mcp-data:

Points that matter:

  • Inside the container the server must bind 0.0.0.0 so the published port works. That is allowed only because MCP_BEARER_TOKEN or OAuth is configured; otherwise it exits with code 2.
  • The port is published on 127.0.0.1 only. The reverse proxy on the same host is the public entry point.
  • DATA_DIR must be a persistent volume writable by the container user. It holds the audit log and the encrypted OAuth token store (oauth-store.json, mode 0600). Losing the volume loses all OAuth logins and the audit history. Back it up, and protect the backup like the secrets it protects.
  • Keep .env out of version control and out of the image (mode 0600; setup writes it that way). Compose passes it as environment, so it is visible to docker inspect for anyone with Docker access.

Check it: curl http://127.0.0.1:8080/health.

Reverse proxy and TLS

Terminate TLS at a reverse proxy. PUBLIC_BASE_URL must be the public https URL (it is required for OAuth, and http is only accepted for loopback hosts). Mistral Le Chat needs a valid public certificate.

Caddy:

gitea-mcp.example.com {
    reverse_proxy 127.0.0.1:8080 {
        flush_interval -1
    }
}

nginx: inside the proxying location, set proxy_buffering off; so streamed responses are not held back. Also forward Host (proxy_set_header Host $host;) and keep the default read timeout in mind for long tool calls.

The server rejects requests whose Host is not allowed (DNS-rebinding protection). Allowed hosts are the loopback names with the port, the host of PUBLIC_BASE_URL, and ALLOWED_HOSTS. If you reach it under another name, add that name to ALLOWED_HOSTS. With a non-loopback MCP_HOST (the container) and neither PUBLIC_BASE_URL nor ALLOWED_HOSTS, every remote Host is rejected with 421, and the server logs a warning at startup naming the two variables.

The server trusts X-Forwarded-For only from FORWARDED_ALLOW_IPS (default 127.0.0.1). When the proxy reaches the container from another address, for example a Docker network, add that address or network (FORWARDED_ALLOW_IPS=172.18.0.0/16). Without it every request looks like it comes from the proxy and shares one per-IP rate limit bucket. * trusts any peer, so use it only when the port is reachable solely from the proxy.

OAuth application in Gitea

setup --public-base-url https://gitea-mcp.example.com registers the application for you as the admin's user application. To do it by hand: in Gitea open Settings, Applications, Manage OAuth2 Applications, create an application named aegis-gitea-mcp as a confidential client with the redirect URI https://gitea-mcp.example.com/oauth/callback, and put the client id and secret in GITEA_OAUTH_CLIENT_ID and GITEA_OAUTH_CLIENT_SECRET. Then set OAUTH_ENABLED=true, PUBLIC_BASE_URL and TOKEN_ENCRYPTION_KEY (at least 32 characters, openssl rand -hex 32).

Gitea has no instance-wide OAuth application API, so the application belongs to the admin account that created it. Re-running setup reuses it; if the stored client secret is missing, Gitea has to regenerate it, and setup asks before doing so.

Before the browser is sent to Gitea, the consent page /oauth/consent shows the client name and the redirect URI, because Gitea remembers consent for this one application and any client can register itself. For a public deployment also set OAUTH_REDIRECT_ALLOWLIST to the hosted clients you use, for example https://claude.ai/,https://chat.mistral.ai/ (see configuration.md).

OAUTH_SCOPES is the scope list sent to Gitea. Keep it to granular scopes. Narrow it (for example to read:repository,read:issue) to limit what signed-in users' tokens can do through this server regardless of their Gitea rights.

Upgrades

  • Application: pull the new image (or package) and restart. The data volume is kept.

  • TOKEN_ENCRYPTION_KEY must stay the same. A store written with another key stops the server at startup (exit code 2, the file is kept) instead of being wiped: restore the old key, or move DATA_DIR/oauth-store.json away to start over, which signs every OAuth user out.

  • Upgrading Gitea: tool names and schemas come from the pinned swagger, so a Gitea upgrade does not change them. To pick up a newer API, re-pin the swagger and regenerate the catalog:

    curl -fsS https://git.example.com/swagger.v1.json -o spec/gitea-swagger.v1.json
    uv run python scripts/generate_catalog.py
    uv run python scripts/check_docs.py --write
    uv run pytest
    

    Review the diff of tools.json and docs/tools.md: renamed or removed operations change tool names. Until the new spec is pinned, gitea_api_request reaches new endpoints.

CI

The workflows in .gitea/workflows/ lint, test and build. The docs-check job runs uv run python scripts/check_docs.py, which fails when docs/tools.md or the README tool summary is stale, or when a document names a tool that does not exist. scripts/generate_catalog.py --check fails when tools.json does not match the pinned swagger.

Security rules of the pipeline:

  • Jobs that use the runner's Docker daemon or a secret (test-integration, test-mcp, build-and-push) do not run for pull requests from forks. Access to the daemon is root-equivalent on the host, and a fork controls the workflow and test code. Also enable "require approval for workflows from forks" (or the equivalent runner setting) in Gitea, and keep the daemon of a shared runner free of services you cannot afford to lose.
  • Every third-party action is pinned to a full commit SHA with the version in a trailing comment. Bump a pin deliberately: look up the SHA of the new tag with git ls-remote https://github.com/<owner>/<action> refs/tags/<tag> (add ^{} for annotated tags). The registry token never reaches a third-party action: the CLI reads it from stdin and uv publish reads UV_PUBLISH_PASSWORD.
  • build-and-push scans the built image with a pinned Trivy (scripts/ci/install_trivy.sh, sha256 verified) before anything is pushed, and fails on HIGH or CRITICAL findings that have a fix. scan.yml repeats the scan and the dependency audit every week, so a new advisory in a pinned base image turns it red: refresh the digests in docker/Dockerfile. Accepted findings go in security/trivyignore.txt with a waiver row in security.md.
  • publish.yml needs the security job (secret scan, security tests, dependency audit, bandit rules) as well as lint and tests.
  • The cleanup script removes containers only for per-run image names (aegis-ci-<run id>-...) and immutable :sha- tags. Shared tags (:latest, :main, :dev) are only untagged.