Serve the API on 3000 and 8000 at once, matching the frontend image

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>
This commit is contained in:
Suriyakumarvijayanayagam
2026-08-13 12:38:35 +05:30
parent 2493b86ed8
commit e47edd2bb7
5 changed files with 215 additions and 24 deletions

View File

@@ -16,6 +16,19 @@
# localhost URL points the backend at its own empty ports. Containers reach
# each other by service name over the compose network instead.
# --- Ports (container only) -----------------------------------------------
# The image serves 3000 and 8000 at once - 3000 because that is what Dokploy
# routes a domain to, 8000 because the README, the vite dev proxy and
# docker-compose all use it. Serving both means the container works whichever
# one the platform points at.
#
# PORTS the comma-separated pair to bind. PORT pins a single port instead and
# takes precedence, e.g. PORT=8080 serves only 8080.
#
# Neither affects running uvicorn directly for local development.
# PORTS=3000,8000
# PORT=8080
# --- Persistence (IMPORTANT in Docker) ------------------------------------
# The three directories the app WRITES to at runtime. The defaults point inside
# the repo/image and are right for local development; in a container each one

View File

@@ -44,6 +44,7 @@ COPY app ./app
COPY cli ./cli
COPY scripts ./scripts
COPY data ./data
COPY serve.py .
# Pristine copies of everything the app also WRITES to, kept at a path that is
# never mounted over.
@@ -68,31 +69,29 @@ RUN mkdir -p /app/.bundled \
# file) name them properly; this is the floor, not the recommended setup.
VOLUME ["/app/data", "/app/app/intelligence/artifacts"]
# The port uvicorn binds. Overridable because Dokploy assigns the container
# port per service - the frontend image answers on both 80 and 3000 for the
# same reason. A single process cannot listen twice, so this is the knob:
# PORT=3000 in the service's environment, if you standardise on 3000.
ENV PORT=8000
EXPOSE 8000
# Liveness only. /api/health always answers 200 - it reports Postgres and
# Ollama in the body as "degraded" rather than failing - which is deliberate
# here: a healthcheck that went red whenever Postgres blinked would have
# Dokploy restart a perfectly healthy API in a loop.
# Answers on BOTH ports, the same way the frontend image does (nginx.conf has
# `listen 80; listen 3000;`). 3000 is what Dokploy routes a domain to; 8000 is
# what this project's README, the vite dev proxy and docker-compose all use.
# Serving both means the container works whichever one the platform is pointed
# at, instead of returning 502 from a perfectly healthy process.
#
# The 10s timeout is not padding: the handler probes Ollama over HTTP with a
# 3s timeout of its own, so an unreachable Ollama makes every check take ~3s.
# serve.py binds both sockets and hands them to one uvicorn - see the note
# there. To pin a single port, set PORT (PORT=8080 serves only 8080); to change
# the pair, set PORTS.
ENV PORTS=3000,8000
EXPOSE 3000 8000
# Liveness only, and passes if EITHER port answers. /api/health always returns
# 200 - it reports Postgres and Ollama in the body as "degraded" rather than
# failing - which is deliberate: a check that went red whenever Postgres blinked
# would have Dokploy restart a perfectly healthy API in a loop.
#
# The 10s timeout is not padding: the handler probes Ollama over HTTP with a 3s
# timeout of its own, so an unreachable Ollama makes every check take ~3s.
# start-period covers first boot, where the venv is still cold.
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD python -c "import os,urllib.request;urllib.request.urlopen('http://127.0.0.1:'+os.environ.get('PORT','8000')+'/api/health',timeout=8)" || exit 1
CMD ["python", "serve.py", "--healthcheck"]
# `exec` matters: without it the shell stays PID 1 and Docker's SIGTERM never
# reaches uvicorn, so every deploy waits out the 10s kill timeout instead of
# shutting down cleanly. sh is only here to expand $PORT.
#
# --proxy-headers/--forwarded-allow-ips: this container is never reached
# directly - nginx (and, in prod, Dokploy's Traefik) sit in front of it.
# Without these, uvicorn ignores X-Forwarded-Proto and reports every request as
# plain http, so any redirect or generated absolute URL would downgrade an
# https request.
CMD ["sh", "-c", "exec uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000} --proxy-headers --forwarded-allow-ips '*'"]
# Exec form: python is PID 1, so Docker's SIGTERM reaches it directly and a
# redeploy shuts down cleanly instead of waiting out the 10s kill timeout.
CMD ["python", "serve.py"]

View File

@@ -69,6 +69,28 @@ permission check; `user` holds the product/store/inventory permissions.
`AUTH_ENABLED=false` disables all of it for local work — never in a deployment.
See the Authentication section of `../DEPLOYMENT.md` for the full endpoint map.
## Ports
The container answers on **3000 and 8000 at the same time**, the same way the
frontend image answers on 80 and 3000. 3000 is what Dokploy routes a domain to;
8000 is what this README, the vite dev proxy and `docker-compose.yml` use. Both
being live means the deployment works whichever one it is pointed at, instead of
returning 502 from a healthy container.
`serve.py` is what makes that possible - uvicorn's CLI binds a single `--port`,
but `Server.run()` accepts a list of pre-bound sockets, so it is still one
process. If one port is unavailable it logs and carries on with the other; it
exits non-zero only when nothing is listening.
```bash
python serve.py # 3000 and 8000
PORT=8080 python serve.py # only 8080 (PORT pins a single port)
PORTS=80,3000 python serve.py # a different pair
```
For local development `uvicorn app.main:app --reload --port 8000` is still the
normal thing to run - one port is all you need, and it gives you autoreload.
## Persistence: the two volumes a deployment needs
Most state lives in Postgres, but three things are written to the filesystem,

View File

@@ -61,6 +61,9 @@ services:
OLLAMA_BASE_URL: http://host.docker.internal:11434
extra_hosts:
- "host.docker.internal:host-gateway"
# The container listens on 3000 and 8000 at once (see serve.py), so either
# side of this mapping can change without touching the image. 8000 is
# published because vite.config.js proxies /api to 127.0.0.1:8000.
ports:
- "8000:8000"
volumes:

154
serve.py Normal file
View File

@@ -0,0 +1,154 @@
"""
Container entry point: serve the API on every port the platform might route to.
The frontend image answers on both 80 and 3000 (``listen 80; listen 3000;`` in
nginx.conf) so it works whatever port the deployment is configured to hit. This
does the same for the API, which otherwise has to guess: Dokploy routes a domain
to one container port, this project's own README, vite.config.js proxy and
docker-compose mapping all say 8000, and picking wrong produces a 502 with a
perfectly healthy container behind it.
uvicorn's CLI binds a single ``--port``, but ``Server.run()`` accepts a list of
already-bound sockets, so one process can listen on several. That is what this
does - no extra worker, no second process to supervise.
Usage::
python serve.py # binds PORTS (default "3000,8000")
PORT=8080 python serve.py # binds only 8080
python serve.py --healthcheck # probe mode, used by HEALTHCHECK
Run it directly rather than through ``uvicorn app.main:app`` when you need the
multi-port behaviour; the plain uvicorn command still works for local
development where one port is all anyone wants.
"""
from __future__ import annotations
import logging
import os
import socket
import sys
import urllib.error
import urllib.request
logger = logging.getLogger("serve")
# Both of the ports this project actually uses anywhere: 3000 because that is
# what the platform routes a domain to by default, 8000 because the README,
# the vite dev proxy and docker-compose all target it.
DEFAULT_PORTS = "3000,8000"
HOST = os.getenv("HOST", "0.0.0.0")
def configured_ports() -> list[int]:
"""
The ports to bind, in order.
``PORT`` wins over ``PORTS`` and is treated as an explicit single choice:
setting it means "serve here", not "serve here as well". ``PORTS`` takes a
comma-separated list for the both-at-once case.
"""
raw = os.getenv("PORT") or os.getenv("PORTS") or DEFAULT_PORTS
ports: list[int] = []
for chunk in raw.split(","):
chunk = chunk.strip()
if not chunk:
continue
try:
port = int(chunk)
except ValueError:
logger.warning("Ignoring non-numeric port %r in %r", chunk, raw)
continue
if not 1 <= port <= 65535:
logger.warning("Ignoring out-of-range port %d", port)
continue
if port not in ports: # binding the same port twice would fail
ports.append(port)
if not ports:
logger.error("No usable port in %r - falling back to %s", raw, DEFAULT_PORTS)
return [int(p) for p in DEFAULT_PORTS.split(",")]
return ports
def _bind(port: int) -> socket.socket | None:
"""Bind one listening socket, or return None with the reason logged."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind((HOST, port))
except OSError as exc:
# Not fatal on its own. If the platform only routes to one of these,
# losing the other (already in use, not permitted) should not take the
# service down - _run() fails only when nothing at all is listening.
logger.warning("Could not bind %s:%d - %s", HOST, port, exc)
sock.close()
return None
sock.listen(2048)
sock.set_inheritable(True)
return sock
def _run() -> int:
import uvicorn
ports = configured_ports()
sockets = [s for s in (_bind(p) for p in ports) if s is not None]
if not sockets:
logger.error(
"Could not bind any of %s. The API is not listening; exiting so the "
"platform restarts or reports the container as failed.",
", ".join(str(p) for p in ports),
)
return 1
bound = [s.getsockname()[1] for s in sockets]
logger.info("Serving on %s port(s): %s", HOST, ", ".join(str(p) for p in bound))
config = uvicorn.Config(
"app.main:app",
# proxy_headers/forwarded_allow_ips: this container is never reached
# directly - nginx, and Dokploy's Traefik, sit in front of it. Without
# them uvicorn ignores X-Forwarded-Proto and reports every request as
# plain http, so any redirect or generated absolute URL would downgrade
# an https request.
proxy_headers=True,
forwarded_allow_ips="*",
)
uvicorn.Server(config).run(sockets=sockets)
return 0
def _healthcheck() -> int:
"""
Probe the API, passing if ANY configured port answers.
Shares configured_ports() with the server so the check cannot drift from
what is actually bound - the reason this lives here rather than being a
python -c one-liner in the Dockerfile.
"""
ports = configured_ports()
for port in ports:
try:
with urllib.request.urlopen(
f"http://127.0.0.1:{port}/api/health", timeout=8
) as resp:
if 200 <= resp.status < 400:
return 0
except (urllib.error.URLError, OSError, ValueError):
continue
print(
f"health: no response from any of {', '.join(str(p) for p in ports)}",
file=sys.stderr,
)
return 1
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
if "--healthcheck" in sys.argv:
sys.exit(_healthcheck())
sys.exit(_run())