Files
..

Docker deployment

Run Dify with Docker Compose. For the default deployment, copy .env.example to .env, fill in the required token, and start the services. Defaults for the API, workers, and supporting services load automatically; additional files are optional.

Start Dify

Before you begin, install Docker with a running daemon, Docker Compose v2.35.0 or later, and OpenSSL to generate the sandbox token. Check your Compose version with docker compose version. Version 2.35.0 or later is required for root .env overrides to take precedence when Compose resolves service defaults.

For an existing deployment, edit your current .env instead of replacing it. For a new deployment, follow these steps:

  1. From the repository root, enter the Docker directory and copy the template:

    cd docker
    cp .env.example .env
    

    Unless noted otherwise, run commands below from docker/.

  2. Generate a token:

    openssl rand -hex 32
    

    Open .env and paste the output as the value of DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN. Keep COMPOSE_PROFILES=weaviate,postgresql,collaboration for the default deployment. No other configuration files are required.

  3. Start the services and check their status:

    docker compose up -d
    docker compose ps --all
    

    Wait for the long-running services to start and their health checks to pass. The one-time init_permissions service is expected to exit with code 0.

  4. Open http://localhost/install and complete the initial setup. If you changed the published HTTP port, use that port in the URL.

Before using this deployment in production, replace the bundled credentials using the security template and the matching service files, and configure HTTPS and trusted proxies.

Configure Dify

Choose the configuration file

File Purpose Loaded directly?
.env.example Minimal template for required deployment inputs No; copy it to .env
.env Deployment inputs and explicit overrides, including Compose settings Yes
envs/**/*.defaults Tracked defaults for the matching services Yes, automatically
envs/**/*.env Optional service or provider overrides Yes, when the matching service references the file
envs/**/*.env.example Templates for optional application and provider settings No; remove .example, except for the middleware template below
envs/middleware.env.example Template for the separate source-code development stack No; copy to middleware.env in docker/

Keep settings with usable defaults out of the root .env.example. To customize a service, copy its .defaults file to the override path named in its comments, usually replacing .defaults with .env in the same directory. Edit the copy, or the existing .env if one is already present. Uncomment only the optional assignments you want to change; leave unused empty values and placeholders commented. For an optional .env.example template, review its comments and fill in the settings before use.

Find settings in:

  • Core services: API, workers, web, plugin daemon, and sandboxes. api-worker.defaults contains the common API, worker, and Beat settings, grouped by function. API process options live in api.defaults; worker autoscaling lives in worker.defaults.
  • Databases: PostgreSQL, MySQL, and Redis.
  • Vector stores: bundled and external vector databases, including their API/worker client settings. Providers without a dedicated template are documented in api-worker.defaults.
  • Infrastructure: nginx, Certbot, proxies, and supporting services.

Change a service setting

For example, to set nginx's upload limit to 200M:

  1. Create the optional override file if it does not already exist:

    cp envs/infrastructure/nginx.defaults envs/infrastructure/nginx.env
    
  2. In envs/infrastructure/nginx.env, replace the input setting with:

    NGINX_CLIENT_MAX_BODY_SIZE=200M
    

    Leave expressions under Container variable mappings unchanged. They translate input names such as SANDBOX_WORKER_TIMEOUT into the container's WORKER_TIMEOUT.

  3. Apply the change and inspect nginx's active configuration:

    docker compose up -d
    docker compose exec -T nginx nginx -T 2>&1 | grep client_max_body_size
    

    The output should contain client_max_body_size 200M;.

Use docker compose up -d after editing configuration files. Compose recreates containers whose configuration changed. docker compose restart does not reload environment variables. No extra --env-file option is needed for this deployment.

Change a published port or another Compose setting

Put settings consumed by Compose in root .env: COMPOSE_PROFILES, published ports, volume paths, resource limits, and health-check intervals. Service env files cannot change those parts of the Compose model.

For example, add or update this line in .env to use HTTP port 8080:

EXPOSE_NGINX_PORT=8080

Run docker compose up -d, then open http://localhost:8080/install. Use root .env for values shared with the isolated local Agent sandbox, including its authentication token.

Change shared plugin credentials

Set both shared plugin credentials in root .env, so every service receives the same values:

Root .env setting Used by
PLUGIN_DAEMON_KEY Plugin daemon authentication and its API, worker, and Agent clients
PLUGIN_DIFY_INNER_API_KEY API inner-endpoint authentication and its plugin daemon, worker, Agent, and KnowledgeFS clients

Generate a separate value for each key with openssl rand -hex 32, then add the two assignments to .env. If you previously set these keys in a service .env file, move them to root .env. Leave the derived container key expressions unchanged when copying service defaults, including the Agent's derived key expressions. Editing only plugin-daemon.env, api.env, or worker.env does not update every consumer and can cause HTTP 401 responses.

Run docker compose up -d to apply the credentials to all affected services. These optional overrides do not need to be added to .env.example.

Select a database or vector store

In root .env, set VECTOR_STORE and update COMPOSE_PROFILES to select the matching bundled vector store. Compose supplies its connection defaults to the API, workers and Beat automatically. For example, a new deployment with Qdrant and PostgreSQL uses:

VECTOR_STORE=qdrant
COMPOSE_PROFILES=qdrant,postgresql,collaboration

Changing VECTOR_STORE selects the default for new indexes. Existing knowledge bases keep using their stored backend, so preserve its connection settings and keep its service running. If any use bundled Weaviate, retain its profile with COMPOSE_PROFILES=weaviate,qdrant,postgresql,collaboration for this example.

Bundled Weaviate connection defaults remain available when another provider is selected. An unchanged bundled service needs no additional connection settings; existing overrides in weaviate.env, api-worker.env, and shared.env still apply, with root .env taking precedence.

To customize the connection, copy the provider's .defaults file under envs/vectorstores/ to its .env path if that file does not already exist. Edit the copy or the existing .env file. Elasticsearch's two selectors use elasticsearch.env. Existing provider .env and shared.env overrides remain supported. Keep VECTOR_STORE in root .env, since Compose selects the defaults file before reading service files.

For an external vector store, omit its bundled profile and provide its connection settings and credentials. For databases with stored user passwords, change the password in the database before updating the corresponding client setting. Starting a different database or vector store does not migrate existing data.

For OpenSearch HTTPS connections, OPENSEARCH_VERIFY_CERTS defaults to true whenever VECTOR_STORE=opensearch, even without the bundled opensearch profile. Keep verification enabled for production and external HTTPS servers, and use certificates trusted by the client.

The bundled OpenSearch image uses demo certificates. For local development with those certificates, explicitly set OPENSEARCH_VERIFY_CERTS=false in envs/vectorstores/opensearch.env or root .env. To keep verification enabled, configure certificates trusted by the client.

For example, the default deployment selects PostgreSQL and Weaviate with COMPOSE_PROFILES=weaviate,postgresql,collaboration. The collaboration profile starts the WebSocket API. Set DB_TYPE and the SQL database's connection settings when changing that provider. See the database and vector store files for provider-specific settings.

For a new deployment using bundled MySQL and Weaviate, set the complete connection in root .env:

DB_TYPE=mysql
DB_HOST=db_mysql
DB_PORT=3306
DB_USERNAME=root
COMPOSE_PROFILES=weaviate,mysql,collaboration

Keep your selected vector-store profile if it differs from this example. The MySQL profile starts the database; it does not replace the PostgreSQL connection defaults. Set DB_PASSWORD to the same value used to initialize the MySQL server. Changing these settings on an existing PostgreSQL deployment does not migrate its data.

Configure sandbox credentials

Set DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN only in root .env, using the secret generated during setup. Compose supplies it to both the Agent backend and its local sandbox. Move any existing copy of this token from dify-agent.env to root .env and remove the duplicate; keep the same secret during the move.

The separate Code sandbox uses two settings that must match: SANDBOX_API_KEY in envs/core-services/sandbox.env and CODE_EXECUTION_API_KEY in envs/security.env (copied from security.env.example). Use the same generated secret in both files, then run docker compose up -d to apply both changes. You can also set both variables to the same value in root .env.

Understand which value takes effect

For services that load root .env, precedence is:

  1. Root .env overrides.
  2. Optional service .env overrides.
  3. Tracked .defaults values, followed by application defaults for unset settings.

Other services, including databases, vector stores, and nginx, receive root values only through ${...} expressions in their defaults or explicit Compose mappings. For those inputs, root .env takes precedence over the service file. Arbitrary native container variables do not inherit this precedence: for example, put Weaviate's ASYNC_INDEXING in envs/vectorstores/weaviate.env; adding it to root .env alone does not configure the Weaviate container.

The knowledge_fs_unstructured parser loads its defaults followed by envs/core-services/knowledge-fs-unstructured.env, without loading root .env. Set its parsing options in that service file.

Shell variables take precedence when Compose interpolates ${...} expressions. An explicit Compose environment entry also takes precedence over service env files; trusted-proxy settings are one example. The KnowledgeFS section describes its restricted root overrides separately.

The API, workers, and plugin daemon also read the database and Redis override files, so shared connection settings reach both the bundled server and its clients. Changing a PostgreSQL or MySQL initialization password does not rotate credentials in an existing database; use the database's credential-rotation procedure as well.

Troubleshoot setup and configuration

Validate the Compose configuration and inspect the affected services:

docker compose config --quiet
docker compose ps --all
docker compose logs --tail=100 api worker nginx

Replace the service names in the logs command with the service you are diagnosing. A successful config --quiet produces no output; it validates the Compose model, not application readiness.

Symptom What to check
Editing a template has no effect Copy .env.example to .env, or a service template to its documented .env path. Templates ending in .example are not loaded.
A service still uses its old value Run docker compose up -d. Check for a higher-priority value in root .env, the shell, or an explicit Compose environment entry.
A port or profile change has no effect Put the setting in root .env; these settings cannot be supplied through a service env file.
Service overrides resolve unexpectedly Check that Docker Compose is v2.35.0 or later and that container-variable mapping expressions are intact.
Python sandbox code returns operation not permitted Check the network-setting note in sandbox.defaults. Sandbox 0.2.15 restricts the epoll_pwait syscall when networking is disabled on both ARM64 and AMD64. The runtime failure has been reproduced on ARM64.

Review configuration after an upgrade

Review the new root .env.example for required deployment inputs and the matching .defaults files for changed service defaults. Preserve your existing .env files; a copied service file can pin values from an older release.

Review any saved OPENSEARCH_VERIFY_CERTS=false: it stays in effect after an upgrade. Remove the assignment or set it to true to enable certificate verification.

Bundled service templates now use the .defaults extension. Existing service .env paths still work. Root .env.example remains the only required starting template, and the Compose generator creates only a missing root .env.

Common API and worker application settings are documented in api-worker.defaults. New customizations use envs/core-services/api-worker.env. Existing shared.env files and other service override paths remain supported; keep local files and edit them in place. If both shared.env and api-worker.env exist, api-worker.env takes precedence for matching settings. Root .env retains precedence over both files, including for settings documented in a provider template.

Review accidental empty assignments left by older template copies. For example, CELERY_WORKER_AMOUNT= overrides the Docker default of 4 and lets the worker entrypoint fall back to 1. Remove or comment out that assignment to restore the Docker default. Likewise, remove accidental empty SERVER_WORKER_CLASS= or GUNICORN_TIMEOUT= entries to restore gevent and 360. Keep intentional overrides; the configuration loader does not rewrite or discard them.

To merge new root-template keys into an existing .env, optionally run:

bash dify-env-sync.sh

The script backs up .env to env-backup/.env.backup_<timestamp>, adds missing keys from .env.example, preserves existing values and extra local overrides, and reports differences for review. It does not synchronize the files in envs/. Review the resulting settings before running docker compose up -d.

Run middleware for development

Use docker-compose.middleware.yaml when running the application from source and you need its supporting services in Docker.

  1. Create middleware.env if it does not exist:

    cp envs/middleware.env.example middleware.env
    
  2. Edit database and other middleware settings, then start the services:

    docker compose --env-file middleware.env -f docker-compose.middleware.yaml -p dify up -d
    

This separate workflow requires --env-file middleware.env. The template defines COMPOSE_PROFILES=${DB_TYPE:-postgresql},weaviate, which selects the database and Weaviate. Update that value if you need different profiles.

Configure HTTPS and telemetry

For HTTPS certificates, follow the Certbot guide.

For OpenTelemetry, create envs/core-services/api-worker.env from api-worker.defaults if it does not exist. Uncomment ENABLE_OTEL and set it to true; uncomment OTLP_BASE_ENDPOINT and set it to your collector's endpoint, reachable from the API and worker containers. The same file documents optional OTEL_* tuning. Apply the changes with docker compose up -d.

Configure worker queues and webhooks

Custom worker queue lists set through CELERY_QUEUES or CELERY_WORKER_QUEUES must include human_input_delivery to process durable Human Input IM inbox events and recover unconfirmed Human Input v2 notifications, and human_input_contact_sync to process manual IM Contact directory synchronization.

Human Input IM channels in WEBHOOK mode build their Callback URL from TRIGGER_URL. Set it to the externally reachable HTTPS origin of the API when configuring a remote deployment. A localhost value is only suitable for local development. Reverse proxies must forward /callbacks/human-input/v2/im/ to the API and preserve the request path, body, and provider signature headers. Copy the channel's Callback URL into the provider's interaction configuration after changing the public origin.

Ops tracing rolling upgrades

New tracing tasks use OPS_TRACE_QUEUE=ops_trace_v2 by default. Set it consistently on the API, workers, and Beat in docker/envs/core-services/api-worker.env (or docker/.env). Default worker subscriptions follow this value; explicit CELERY_QUEUES or CELERY_WORKER_QUEUES lists must include it and exclude legacy ops_trace while old workers are draining.

Keep an old tracing worker pool on ops_trace until old producers stop and their queued, active, reserved, scheduled, and retry tasks finish. To return to ops_trace, first remove old workers, have new workers consume both queues, then change OPS_TRACE_QUEUE on all new processes and drain ops_trace_v2 before removing its consumers. See the complete rollout sequence.

Enable the KnowledgeFS integration

The optional knowledge-fs Compose profile starts the internal KnowledgeFS API service and the dedicated Dataset upgrade worker. The API service publishes no host port and has no nginx route. Model, datasource, and object-storage calls go through Dify's inner API. Dify resolves active Workspace/plugin configuration and remains the only owner of physical storage credentials. KNOWLEDGE_FS_ENABLED remains false, so starting the container does not enable product traffic.

Copy the dedicated service template if the local file does not already exist:

cp envs/core-services/knowledge-fs.env.example envs/core-services/knowledge-fs.env

Fill in the operator-owned inputs described in knowledge-fs.env before starting the services. The default image is the CI-published deployment-branch image; set KNOWLEDGE_FS_API_IMAGE to pin another tag or immutable SHA tag. If the image repository is private, authenticate with docker login before pulling:

docker compose --profile knowledge-fs --profile knowledge-fs-unstructured config --quiet
docker compose --profile knowledge-fs --profile knowledge-fs-unstructured pull knowledge_fs knowledge_fs_unstructured
docker compose --profile knowledge-fs --profile knowledge-fs-unstructured up -d

The service env file intentionally contains only operator-owned inputs: the dedicated database, durable compilation switch, Capability v2 public verification material, and optional Unstructured endpoint. Compose injects the Dify inner API connection and integrated mode. KnowledgeFS stores objects below an internal namespace in Dify's configured STORAGE_TYPE; do not duplicate Dify's bucket, endpoint, or provider credentials in knowledge-fs.env. Feature-specific rollout flags and capacity tunables should be added only when deliberately overriding their safe runtime defaults.

Configure document parsing

The optional knowledge-fs-unstructured profile starts one isolated parser service named knowledge_fs_unstructured for every KnowledgeFS remote format. Its tracked knowledge-fs-unstructured-service.defaults additionally enables bounded page parallelism for PDFs and a 25-million-pixel pre-allocation limit per page at the pinned 350 DPI; an optional copied knowledge-fs-unstructured.env can override those service values. Do not raise the DPI or pixel limit, or disable the guard: the KnowledgeFS client estimates page rasters at 350 DPI and rejects oversized or unverifiable page geometry before sending it to the parser. Oversized pages need smaller page dimensions or tiling before import; reducing compressed file size alone does not help. Existing installations need the new KnowledgeFS image and a recreated parser service to activate both guards. The existing unstructured profile remains unchanged for Dify's legacy ETL, so KnowledgeFS tuning cannot alter its PDF or Office parsing behavior. The copied knowledge-fs.env pairs every PDF and structurally/byte-heavy remote document with the longer deadline and narrower admission gate; ordinary formats retain two concurrent requests and a 600-second timeout, while heavy work uses one concurrent request and a 2,400-second timeout. The remote input cap defaults to 15 MiB so the parser accepts the complete product upload envelope. The legacy UNSTRUCTURED_PDF_* names remain lower-precedence compatibility aliases. The isolated service is available to KnowledgeFS at http://knowledge_fs_unstructured:8000; it does not publish a host port.

Configure PDF image rendering

The KnowledgeFS API image includes Poppler and enables its pdftoppm PDF image rasterizer with bounded defaults (144 DPI, 48 DPI thumbnails, a 30-second timeout, at most 500 assets per document, and two concurrent Poppler page batches per replica). Canonical settings live in the dedicated knowledge-fs.env; whitelisted DIFY_ROOT_*_OVERRIDE proxies let explicitly set values in docker/.env take precedence without injecting the complete root environment. When a root value is unset, the service env or image default remains authoritative. Set KNOWLEDGE_PDF_RASTERIZER=off in either operator env as an emergency or low-resource kill switch. Existing documents whose parse artifacts lack image asset references must be ingested again after rollout.

Check storage and readiness before enabling traffic

Use a dedicated KnowledgeFS database. Do not point DATABASE_URL at Dify's application database, reuse Dataset/Document tables, or run a data migration as part of this service. KnowledgeFS migrations remain a separate controlled operator step. The selected Dify storage backend must support recursive scan; the currently verified paths are S3 and OpenDAL/local.

The production entrypoint supports the explicitly selected Capability v2 verifier and accepts only public JWKS material. The knowledge_fs container can still return 200 from /health while /ready returns 503 when the verifier or another durable dependency is missing; this is intentional fail-closed behavior. Do not add a proxy route, set KNOWLEDGE_FS_ENABLED=true, or send product traffic until readiness returns 200 and the migration and per-Workspace cutover gates are complete.

Run lifecycle and upgrade workers

The standard Dify worker consumes knowledge_fs_lifecycle for provisioning, deletion, initial source imports, and staged-upload cleanup. Deployments overriding CELERY_QUEUES or CELERY_WORKER_QUEUES must include this queue in a worker pool. Enable the lifecycle rollout gates on the API, workers, and Beat before creating spaces.

Classic Dataset upgrades use the separate worker_knowledge_fs_upgrade service. It starts with the knowledge-fs Compose profile, so enable that profile before submitting an upgrade. This worker must use CELERY_WORKER_POOL=prefork; for local development, run ./dev/start-worker --queues knowledge_fs_upgrade --pool prefork from the repository root. Each checkpoint has a 570-second soft limit and a 600-second hard limit. Do not override these task limits: recovery relies on the child having stopped before reclaiming its execution window. Beat checks for abandoned running jobs and publication claims every minute after the lifecycle gates are ready. Claims without progress for 11 minutes can be recovered automatically or through the upgrade retry endpoint. Stop existing upgrade workers before deploying this recovery change, then recreate the API, worker, upgrade worker, and Beat with the new image and pool configuration.

Document reconciliation has its own durable delivery lease after the parent upgrade succeeds. Beat also scans successful upgrades with unfinished document checkpoints, so a failed publication or interrupted reconciliation is retried without restarting the upgrade. Unclaimed successful jobs are eligible immediately; lost deliveries are reclaimed after the 11-minute lease expires, and completed attempts with unfinished work become eligible again after one minute. Apply the b9d3f7a5c102 migration before starting the updated processes; existing successful upgrades receive NULL leases and are picked up automatically. Reconciliation uses the same prefork pool and 570/600-second task limits. Keep Beat running with the lifecycle rollout gates enabled.

Upgrade uploads retain their logical document identity before compilation finishes, so pending or failed documents can have their legacy disabled state restored without an active revision. Reconciliation releases a deleted document's checkpoint only after verifying a completed durable deletion receipt for that identity; a missing document or upstream error alone is not sufficient. Custom metadata is restored from the snapshot's field definitions, converting Classic UTC epoch seconds to ISO timestamps for time fields. Classic built-in fields are omitted because KnowledgeFS owns its system metadata. These corrections also apply when retrying existing unfinished checkpoints and require no additional migration.

Client IP and trusted proxies

Dify uses the request's resolved client IP for rate limits and audit records. The bundled Nginx overwrites X-Forwarded-For with $remote_addr, and Compose enables RESPECT_XFORWARD_HEADERS_ENABLED for the API and WebSocket API services. Dify's proxy middleware trusts one forwarded value, so different visitors keep separate IP buckets while caller-supplied IP headers cannot override Nginx's address.

The middleware does not authenticate the proxy. Keep the API port private and restrict direct API connections to trusted callers. Compose does not publish that port, but services on its shared Docker networks can still connect directly, including the code sandbox on ssrf_proxy_network. Those connections bypass Nginx's header replacement. If those workloads are untrusted, enforce network or firewall isolation and route their API calls through a sanitizing gateway before enabling forwarded-header trust. Merely setting a Docker network to internal does not isolate containers from each other.

For a directly exposed API, leave RESPECT_XFORWARD_HEADERS_ENABLED=false. The Python configuration and api/.env.example retain that default. To override the Compose default, set the variable in docker/.env, the shell used by Compose, or a Compose override. The API services' explicit environment entry takes precedence over envs/core-services/api-worker.env and legacy shared.env files. Existing false overrides in docker/.env, the invoking shell, or a Compose override remain in effect during upgrades. Move settings previously supplied only by api-worker.env or shared.env to one of those override locations if you need to preserve them. After establishing the trusted ingress boundary, enable the setting to retain visitor IPs.

Cloudflare or another load balancer before Nginx

With an upstream proxy, configure Nginx to resolve the visitor address before forwarding it. Otherwise $remote_addr is the upstream proxy's address, and its visitors share a rate-limit bucket. Add an operator-managed docker/nginx/conf.d/real-ip.conf; the existing mount and Nginx include load this file without changes to the entrypoint.

For a load balancer that supplies a correctly maintained X-Forwarded-For chain:

# Replace this documentation address with the actual trusted proxy address/CIDR.
set_real_ip_from 192.0.2.10/32;
real_ip_header X-Forwarded-For;
real_ip_recursive on;

For Cloudflare connecting directly to Nginx, use real_ip_header CF-Connecting-IP and set_real_ip_from entries for Cloudflare's current published IPv4 and IPv6 ranges. Restrict origin access to the intended Cloudflare path; a Tunnel or Authenticated Origin Pulls with your own certificate can authenticate your zone. Cloudflare IP ranges alone identify its network, not your zone. If another load balancer sits between Cloudflare and Nginx, validate the source and sanitize headers at that load balancer too. Do not trust an arbitrary Cloudflare header merely because an internal load balancer forwarded it.

Never use 0.0.0.0/0, ::/0, or an entire untrusted workload network as the trusted proxy range. Reload Nginx after updating its configuration. See the Nginx real-IP module, Cloudflare IP ranges, and Cloudflare origin protection.

Kubernetes

Use stable workload selectors to restrict API access; individual ingress pod IPs do not need to be configured in Dify. The ingress must resolve and overwrite the forwarded client IP as above. Keep the API Service as ClusterIP, set RESPECT_XFORWARD_HEADERS_ENABLED=true on its Deployment, and enforce a NetworkPolicy on the API pods. ClusterIP alone does not restrict other pods.

This example must be adapted to your namespaces, pod labels, and API port:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: api-from-ingress
  namespace: dify
spec:
  podSelector:
    matchLabels:
      app: dify-api
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: ingress-system
          podSelector:
            matchLabels:
              app: ingress-controller
      ports:
        - protocol: TCP
          port: 5001

Both selectors belong to the same from entry, so the namespace and pod labels must match. Keep that namespace restricted to trusted workloads. Confirm that your CNI enforces NetworkPolicy, no other policy broadly allows API access, and any hostNetwork ingress is covered by your CNI's policy behavior. Allow rules from multiple policies are additive. See the Kubernetes NetworkPolicy documentation.

Inventory internal API callers before applying the policy. Route untrusted callers through the same sanitizing ingress; directly allowed callers can supply forwarded headers too. Internal requests without a verified visitor address are attributed to the calling service, not an end user. Preserve required internal service connectivity while enforcing this boundary.

Before rollout, verify that two distinct visitor IPs use different limit buckets, changing client-supplied IP headers does not change the resolved address, and an untrusted pod cannot connect directly to the API. IP limits still need independent account/email limits because NAT can share public addresses and attackers can use multiple real addresses.