Step 15 — Splitting the front end off¶
About 35 minutes. Branch: step-15.
The one new idea: one app, one URL, services mounted on paths.
This step needs path-based routing enabled on your environment
If mecca app redeploy refuses with "path-based routing (one URL per app) is not
available yet", yours does not have it on. Ask whoever runs it, or skip to
step 16 — step-14 is a complete, working
application and 16 and 17 follow from it.
The problem worth fixing¶
Since step 6 the React bundle has been inside the Python image. So a one-word copy change rebuilds Python, reinstalls pip packages and replaces a container — and the bundle is served by your container rather than from the edge.
Splitting them fixes both. What it must not do is give Tally two URLs and a cross-origin problem.
Three services, one hostname¶
deploy:
- name: web
primary: true
type: static
visibility: public
static_site:
build_command: "cd frontend && npm ci --no-audit --no-fund && npm run build"
output_dir: frontend/dist
spa_fallback: true
index_document: index.html
error_document: index.html
environment:
VITE_API_BASE: "${service:api:path}"
- name: api
container: tally-api
path: /api
visibility: public
port: 8000
- name: worker
container: tally-api
command: "python -m app.worker"
Read what each one declares, and what it does not:
webhas nopath:. That is what makes it the front door — it answers for everything the API has not claimed. The front door is the one service that cannot declare a path.apiis mounted at/api. It claims that prefix on the same hostname.workerhas neither a port nor a path, so it is not reachable from outside at all. Correct for a worker.
The trap: the mount is not stripped¶
⚠ A request to /api/items reaches your container as /api/items. The mount
selects which service gets the request; it does not remove the prefix.
So Tally's routes are written /api/... to match, and they always have been.
Get this backwards — mount at /api and serve /items — and every route 404s
while the config looks perfect.
${service:api:path}, and its three siblings¶
Resolved in the build, before vite build runs, so it is compiled into the
bundle. It evaluates to /api.
| Form | Gives you | Use it when |
|---|---|---|
${service:api} |
https://tally-…appmecca.net |
you need an absolute URL |
${service:api:url} |
the same, spelled out | you want the intent obvious |
${service:api:path} |
/api |
same app, same hostname — this case |
${service:api:internal} |
a private address | container to container, never through the internet |
:path is the right one here, and the reason is the point of the whole step:
a frontend served from the same hostname should never have to be told what that
hostname is. No CORS, no preflight, no environment-specific URL to get wrong.
Which is also why step 15 could not have come earlier
Two separate apps would mean two origins, and the frontend's first
cross-origin request would fail its preflight — which cannot follow a
redirect and cannot carry credentials. The error would say
No 'Access-Control-Allow-Origin' header while your CORS config was
perfectly correct.
One URL removes the problem instead of configuring around it.
One constraint to know¶
A static service can be the front door, or it can be its own app. It cannot be mounted on a non-empty path — the bundle is uploaded to the root of its prefix and the mount is not stripped, so every request would 403.
Nor can a private app take this shape: a private app is served through the auth gateway, which has no route to storage. Tally is public, so this is fine.
Deploy and check both halves¶
Then confirm they are one app on one hostname: the board loads at /, and
/api/items returns JSON on the same host.
If it didn't work¶
| Symptom | Try | Usually |
|---|---|---|
| Refused: path routing unavailable | — | Not enabled here. Stay on step-14 |
| Every API call 404s | — | Routes not written under the mount |
| Board loads, API calls fail | check VITE_API_BASE in the bundle |
The build did not resolve it |
| Refused: static service on a path | — | A static service may only be the front door |