Docs / Architecture / Deployment
Explanation For engineers

Deployment

One Linux server runs the whole factory: nginx at the edge, one gunicorn process behind it, and one directory both may write to. Outside that box are the handful of systems it can be configured to reach, and the two numbers that say which version and which exact commit are running.

On the box

nginx is the only thing the network ever reaches. It terminates TLS and proxies everything under the configured prefix (DPF_PREFIX, typically /dataproductfactory) straight to gunicorn on 127.0.0.1, loopback only — binding the public interface instead would quietly run a second, unencrypted copy of the site next to the one nginx is protecting. proxy_pass carries no trailing slash on purpose. With one, nginx would strip the matched location before forwarding it on; without one, the request arrives with the prefix still on it, and _Mounted (dpf/web/__init__.py) does the stripping itself, moving the prefix onto SCRIPT_NAME so every url_for call still builds a link with the prefix already on it. ProxyFix then trusts exactly one hop of X-Forwarded-For and X-Forwarded-Proto, so the application sees the visitor's real address and https, never nginx's own loopback connection.

The location block sets no Cache-Control of its own: the application decides, response by response — private, no-store on every page and on the API, a year on a static file asked for by its own hash (static assets). proxy_no_cache and proxy_cache_bypass keep nginx itself from caching anything, because almost everything behind the prefix depends on who is asking. It also caps a request body at 4 MB, for an uploaded section of a data contract v0.1; the application's own cap (MAX_CONTENT_LENGTH, dpf/web/__init__.py) is stricter, at 2 MB, so anything in between the two reaches the application and is refused there, by its own 413 handler, rather than by nginx.

Behind nginx, one gunicorn process runs two workers under systemd, started as wsgi:app — wsgi.py's only job is calling create_app() once, under the name gunicorn was told to import. Type=notify means systemd waits for gunicorn's own readiness signal before marking the unit active, rather than reporting a restart "up" the instant the process starts and before a worker has actually bound the port. The rest of the unit is the hardening any small service on a shared box should have, each line answering one question: NoNewPrivileges stops the process gaining anything it did not start with; PrivateTmp gives it a /tmp nothing else on the box can see into; ProtectSystem=strict makes the whole filesystem read-only to it; ProtectHome removes every real user's home directory from view; and ReadWritePaths is the one exception carved out of all of that — var/, and nothing else. Restart=on-failure with RestartSec=3 is a different kind of safety: gunicorn comes back a few seconds after dying, rather than leaving the prefix answering nothing until somebody notices.

The environment file (/etc/dataproductfactory.env) is root-owned and mode 600, and the unit loads it with EnvironmentFile= rather than holding the values itself, because a unit under /etc/systemd is world-readable — a secret written into it would not be a secret.

var/ is the one path ReadWritePaths names, and the only thing the application ever writes to disk: the control-plane SQLite file, and, when the warehouse is DuckDB rather than Databricks, the warehouse file beside it.

Mounted inside another site rather than run on its own domain, DPF_PREFIX is what makes url_for build every link with that prefix already on it. SECRET_KEY has to be the host site's own key in that arrangement: reading its session cookie means signing with the same key, and a different one here would not just fail to read it — it would sign the host site's own visitors out. See Identity for what that session cookie goes on to mean.

ONE LINUX SERVERBrowsera person, overTLSAgents andsystemsa service token,over the APIdeploy/nginx-location.confnginxterminates TLS,proxies the prefixdpf/web/__init__.pyData Product Factorygunicorn, two workers,loopback onlydpf/db.pyvar/the control plane,and the DuckDB filedeploy/dataproductfactory.serviceEnvironment fileroot-owned, mode 600
One Linux server. What runs on the box, left to right from the network, and who reaches it from outside.

What leaves the box

Unconfigured, the factory reaches nothing outside itself: no door, DuckDB in var/, a bundled JSON catalogue, and the agents unavailable. Each of the following is a separate connection, made only once its own settings are set — never because another one already is.

Reaches Only once
Databricks SQL, the warehouse DPF_WAREHOUSE_URL names it
Microsoft Entra ID, identity DPF_IDENTITY=entra
Anthropic, the model an agent step runs, and DPF_LLM_DATA is not off
Azure DevOps or Jira, the delivery tracker a build plan is pushed
ServiceNow, access requests an access request is raised
Informatica, or Unity Catalog through the warehouse connection the data catalogue is read
Data Product Factorydpf/warehouses/WarehouseDatabricks SQL, onceDPF_WAREHOUSE_URL names itdpf/web/entra.pyMicrosoft Entra IDonly once DPF_IDENTITY=entradpf/llm.pyModel providerAnthropic, once an agent step runsdpf/trackers/Delivery trackerAzure DevOps or Jira, once a plan ispusheddpf/itsm.pyAccess requestsServiceNow, once a request is raiseddpf/catalog.pyData catalogueInformatica or Unity Catalog, onceconfigured
What leaves the box. Every connection the factory can make outward, and what switches each one on.

What ships, and which version is running

A deployment is the application's own code, its templates and static files, the documentation, and two small files at the repository root: VERSION and BUILD. version.current() (dpf/version.py) reads VERSION once per process — a deploy restarts gunicorn, so reading it twice would only ever answer the same question twice — and the sidebar, the CLI, the API and the documentation's own header all print it from there. version.build() answers a different question, which commit is actually running: DPF_BUILD first, then a BUILD file the deploy writes, then git rev-parse --short HEAD when the checkout still has .git. The two can legitimately disagree — a box a commit behind the tag it meant to run shows the same version and a different build. See Deploy for the steps that keep the two in step.

The limits of this shape

Two gunicorn workers write to the same SQLite file. WAL mode and a 30-second busy_timeout (dpf/db.py) let the second queue for the lock rather than fail with database is locked, but queueing is not concurrency, and a third worker would only queue more often, not raise a ceiling that is not there. The warehouse cannot absorb that write path either: DuckDB's own multi-process story is "don't" (dpf/warehouse.py). One box, one gunicorn unit, two workers queuing behind one writer is the shape that SQLite's and DuckDB's own write stories hold the design to today — see Quality for the consequence of that ceiling and what would retire it.

Documentation for version 0.2.0 Reviewed 2026-10-19 Covers deploy/*, wsgi.py, dpf/web/__init__.py, dpf/version.py, dpf/db.py, dpf/warehouse.py