The container was never getting its configuration, so it started with nothing
set and Traefik reported a Bad Gateway on every route.
Dokploy writes its own .env into the build context from the service's
Environment tab AFTER cloning the repository. That tab is empty, so it wrote a
zero-byte file over the committed one, and `COPY .env .` faithfully copied the
empty result into the image. The checkout showed it exactly: every file
timestamped 08:33, and .env alone at 08:34 with a size of 0. Inside the running
container, /app/.env was 0 bytes.
Nothing about this is visible from the outside. The build log shows the COPY
succeeding, the image is produced, and the platform reports only a 502.
Dokploy does not manage .env.production, so the config now travels under that
name and the Dockerfile copies it to /app/.env in the image. Anything set in the
Environment tab still wins at runtime, because settings.py calls load_dotenv()
without override=True.
Verified by reconstructing the build context the way Dokploy does - git archive
of HEAD, then an empty .env written over it - applying .dockerignore and the
COPY lines, and booting the result with an empty environment: /app/.env is 4938
bytes, the sign-in passwords are absent, and scripts/check_deploy.sh reports 6
passed, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Separates three failures that are indistinguishable from a browser, and which
this deployment hit in sequence:
502 on every route the container is not running - it exited at startup,
so nothing reached the app and no route is special
200 + database:false the API is healthy, Postgres is not
200 + database:true working
It also catches AUTH_ALLOW_ANY_LOGIN being left on, by asserting that a
deliberately wrong password is rejected. That setting is a convenience locally
and a total auth bypass on a published host, and nothing else about a running
deployment looks different when it is true.
./scripts/check_deploy.sh
./scripts/check_deploy.sh http://localhost:3000
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The configuration was committed but excluded twice over: .dockerignore listed
.env, and the Dockerfile's explicit COPY lines never mentioned it. So the image
built cleanly, the container started with no configuration at all, and exited on
the first required setting - the same RuntimeError and the same Bad Gateway that
committing .env was meant to fix.
Silent in both directions. The build log shows every COPY succeeding, and the
platform reports only a 502, because the process is gone before it can say
anything. Nothing about "build completed" hints that the container has no
configuration.
Verified by reconstructing the image filesystem from the Dockerfile's COPY
lines with .dockerignore applied, then booting from it with an empty
environment: /app/.env is present, SIGNIN_PASSWORDS.txt is not, and /, /docs and
/api/health all answer 200. That reconstruction is the check the earlier "boots
from .env alone" testing was missing - it ran on the host, where .env sits next
to app/ whether the image would have contained it or not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two faults compounded into one symptom: with the database unreachable, every
route on the service returned 502 - including /docs, which never touches it.
_connect() passed no connect_timeout. A host that DROPS packets rather than
refusing them, which is what a firewall or a wrong DB_HOST looks like, blocked
until the OS gave up - roughly 130 seconds on Linux. Every caller inherited
that, /api/health included. Now bounded by DB_CONNECT_TIMEOUT_SECONDS,
defaulting to 5. Measured against an unroutable host: /api/health went from
hanging past 25s to answering 200 in 5.07s.
The container healthcheck then probed /api/health, so that hang timed out the
check, the container was marked unhealthy, and the platform stopped routing to
it. That is the part that turned a degraded dependency into a total outage, and
it was introduced with the healthcheck itself.
A healthcheck is a LIVENESS question, because the platform's answer to "no" is
to take the container out of service. It may only ask whether the process is
still serving HTTP. /api/health is a READINESS report - it dials Postgres and
Ollama to say whether they are reachable, and coupling the container's
existence to its dependencies is what made a running API unreachable. It now
probes "/", which is served from memory and does no I/O, so it can fail only if
the app really is gone.
Verified with an unroutable DB host: /, /docs and /openapi.json all answer 200,
and the healthcheck exits 0. With the app stopped it still exits 1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the live database, DigitalOcean Spaces and Google CSE settings supplied by
the repo owner, so the container needs nothing set in the Dokploy UI.
Three deliberate departures from the development .env this came from:
AUTH_ALLOW_ANY_LOGIN is false, not true. In development it is a convenience -
the password field is not checked, so any username signs in and `admin` reaches
the admin pages. On a host published to the internet it means anyone who finds
mcp.nearle.ai.in becomes admin by typing anything at all. The development file's
own comment says to turn it off before the backend leaves the laptop.
The auth secrets are the freshly generated ones, not the development values.
Those hashes are for the passwords DevAdmin!2026 and DevUser!2026, which sit in
plaintext in test_login_fix.py in this same repository - committing them would
have published working admin credentials alongside the hash that accepts them.
Verified: DevAdmin!2026 is now rejected with a 401.
USE_OLLAMA is false. The development value http://localhost:11434 cannot work
from inside a container, where localhost is the container rather than the VPS
host. Left on with nothing listening, /api/chat fails and every healthcheck
takes ~3s longer, because the health handler probes Ollama with a 3s timeout.
Enable it by pointing OLLAMA_BASE_URL at something the container can reach.
DB_NAME is stated explicitly rather than relying on settings.py's default,
which is what the development file was leaning on.
Verified booting from this file alone, with no environment variables: binds
3000 and 8000, mounts MCP, correct password returns a token, and both the
any-password bypass and the old development password return 401.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
At the repo owner's instruction, to stop the deploy depending on re-entering
config in the Dokploy UI - which is how the container ended up exiting at
startup on missing AUTH_SECRET_KEY and returning Bad Gateway.
The two database secrets are deliberately NOT in the file. settings.py calls
load_dotenv() without override=True, so a real environment variable wins over
the file; DB_HOST and DB_PASSWORD are set in Dokploy and never enter git. Two
fields to fill instead of seven.
The auth secrets ARE committed, which is worth being explicit about:
AUTH_SECRET_KEY signs every access token, so anyone with read access to this
repository can mint a valid admin token, and git history retains it after any
rotation. .gitignore records the same warning next to the exception that allows
the file. Regenerate with scripts/make_auth_secrets.py and redeploy if that
stops being an acceptable trade.
The generated sign-in passwords are written to SIGNIN_PASSWORDS.txt, which
stays ignored - only the PBKDF2 digests are in .env, and those cannot be
reversed.
Verified end to end: the app boots on 3000 and 8000 with DB_HOST/DB_PASSWORD
supplied as environment variables, and a login with the generated admin
password returns a token while a wrong password returns 401.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The deployment landed on mcp.nearle.ai.in, not the mcp.catalogue.nearle.ai.in
these references were written against. Only comments, README and .env.example
are affected - nothing reads the hostname at runtime - but a wrong host in the
connection snippet is a wrong host somebody pastes into an MCP client.
API_CORS_ORIGINS is unchanged: it takes the FRONTEND's origin
(catalogue.nearle.ai.in), not the API's, so moving the API does not affect it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a FastMCP server mounted onto the existing FastAPI app, so it ships in the
same container and answers on the same host rather than needing a process of
its own.
Fifteen tools, all read-only: catalog search and browse, nutrition facts and
health scores, healthier alternatives, per-store inventory and pricing,
discounts, trending and sales analytics. Each wraps a service function the REST
API already reaches through a GET. None of the write or compute endpoints are
exposed, because a tool list is chosen from by a model rather than by a person,
and catalog generation or model retraining is not something to leave one tool
call away.
The tools are written by hand rather than generated from the OpenAPI schema.
Mirroring all 63 routes would work, but a model picks a tool by reading its
description, and 63 near-identical generated entries is a worse thing to choose
from than a dozen written to be told apart.
Authentication reuses the access token from POST /api/auth/login - no separate
MCP credential, the same Principal and expiry as the REST API. The check lives
in one middleware rather than at the top of each tool, so a tool added later
cannot be left unguarded by forgetting a line. Note that this requires passing
include={"authorization"} to get_http_headers(), which strips that header by
default to avoid forwarding it downstream; without it the header is invisible
and every request looks unauthenticated, valid ones included.
Mounting a sub-app does not run its lifespan - only the outermost app's is
executed - so the MCP app's lifespan is chained through the FastAPI one. Without
that the endpoint accepts a connection and then fails on the first message with
a session manager that was never started.
Adds GET /api/mcp/info and POST /api/mcp/tools/{name} for the admin UI. The MCP
endpoint speaks streamable HTTP with session handling, so rendering a tool list
in the browser would otherwise mean shipping a full MCP client in React.
fastmcp needs Python 3.10+. The image is 3.11; on anything older the import
fails and the REST API starts without the MCP endpoint instead of not starting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dokploy routes the domain to port 3000, but the container only bound 8000, so
the proxy had nothing to talk to and the domain returned 502 with a perfectly
healthy process behind it.
The frontend image already solved this by answering on both 80 and 3000
(`listen 80; listen 3000;` in nginx.conf). Do the same here rather than swap one
guess for another: 3000 is what the platform routes to, and 8000 is what the
README, the vite dev proxy and docker-compose all target, so binding both means
the container works whichever one it is pointed at.
uvicorn's CLI takes a single --port, but Server.run() accepts pre-bound
sockets, so serve.py binds each port and hands the list to one uvicorn - no
extra worker or second process to supervise. PORT still pins a single port for
anyone who wants one; PORTS changes the pair.
A port that cannot be bound is logged and skipped rather than being fatal,
since losing one of the two should not take down a service the platform only
routes to on the other. It exits non-zero only when nothing is listening at
all, so a genuinely dead container is still reported as failed.
The healthcheck moves into the same file and passes if either port answers,
which keeps it from drifting out of sync with what is actually bound.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
/api/upload/nutrition called json.dumps() in a module that never imported
json, so every request to it raised NameError, was swallowed by the broad
except, and came back as "500 Database import failed". Import json.
Persist the three directories the app writes to at runtime. Products added
through the UI are appended to data/seed_catalogs/*.json and retrained models
are written to app/intelligence/artifacts/*.joblib; both live inside the image,
so a redeploy silently discarded them. The paths now come from settings
(DATA_DIR / SEED_CATALOG_DIR / MODEL_ARTIFACTS_DIR) so a volume can be mounted
on them, and catalog_engine.save_catalog resolves against DATA_DIR instead of
a working-directory-relative "data/", which landed somewhere different
depending on where the process was started from.
Mounting those volumes would otherwise have made things worse: Docker seeds a
named volume from the image on first use, but a bind mount starts empty and
just hides what the image shipped. A bind mount on /app/data would have left
the API with no seed catalogs, so the next product added would write a JSON
file containing only that product. The image now keeps pristine copies at
/app/.bundled, and restore_bundled_assets() tops up whatever a freshly mounted
directory is missing at startup without overwriting anything already there.
Configure CORS for the split-domain deployment: the React app is served from
catalogue.nearle.ai.in and calls the API on mcp.catalogue.nearle.ai.in, so the
frontend origin has to be in API_CORS_ORIGINS. A wrong list fails only in the
browser while the server logs a healthy 200, so the effective origins are now
logged at startup with a warning when they are localhost-only.
Fix FRONTEND_DIST, which looked for a sibling "frontend/" directory that is
actually named "catalogue_frontend/", so the single-port unified-serving branch
could never activate even with a build sitting next to it.
Rebuild the Dockerfile on the frontend's multi-stage pattern: dependencies
resolve into a venv in a build stage, the runtime stage copies only that.
Adds PYTHONUNBUFFERED so startup errors reach Dokploy's log pane, a liveness
HEALTHCHECK (/api/health answers 200 even when Postgres is down, so a database
blip cannot restart-loop the container), and an overridable PORT. The CMD execs
uvicorn so SIGTERM reaches it rather than the sh wrapper.
Add "from __future__ import annotations" to ollama_service and image_search,
which used PEP 604 unions in runtime-evaluated signatures and so could not be
imported below Python 3.10.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>