chore: update frontend and backend URLs to use port 5181 for local development

This commit is contained in:
Ashwin 2026-09-26 17:24:25 +05:30
parent e26ab380fc
commit 3f46e09aed
19 changed files with 41 additions and 37 deletions

View file

@ -37,4 +37,4 @@ GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
# Frontend # Frontend
PUBLIC_DJANGO_API_URL=http://localhost:8000 PUBLIC_DJANGO_API_URL=http://backend:8000

View file

@ -61,7 +61,7 @@ cd Django-CRM
docker compose up --build docker compose up --build
``` ```
Frontend at http://localhost:5173, API and Swagger UI at http://localhost:8000/swagger-ui/. See Frontend at http://localhost:5181, API and Swagger UI at http://localhost:8000/swagger-ui/. See
the [Docker quick start guide](docs/getting-started/docker-quick-start.md) for the default admin the [Docker quick start guide](docs/getting-started/docker-quick-start.md) for the default admin
login, loading demo data, and what each service does. login, loading demo data, and what each service does.

View file

@ -11,7 +11,7 @@ DOMAIN_NAME="http://localhost:8000"
# CSAT survey, and the internal "assigned to you" notifications. Outside dev # CSAT survey, and the internal "assigned to you" notifications. Outside dev
# (ENV_TYPE other than "dev") a loopback value is refused at startup, because # (ENV_TYPE other than "dev") a loopback value is refused at startup, because
# left at this default it mails customers a link to their own machine. # left at this default it mails customers a link to their own machine.
FRONTEND_URL="http://localhost:5173" FRONTEND_URL="http://localhost:5181"
# AWS (only needed for production / S3 storage) # AWS (only needed for production / S3 storage)
AWS_BUCKET_NAME="" AWS_BUCKET_NAME=""
@ -44,8 +44,8 @@ DEFAULT_FROM_EMAIL="noreply@localhost"
ADMIN_EMAIL="admin@localhost" ADMIN_EMAIL="admin@localhost"
ALLOWED_HOSTS=localhost,127.0.0.1 ALLOWED_HOSTS=localhost,127.0.0.1
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 CORS_ALLOWED_ORIGINS=http://localhost:5181,http://127.0.0.1:5181
CSRF_TRUSTED_ORIGINS=http://localhost:5173 CSRF_TRUSTED_ORIGINS=http://localhost:5181
# Set to True only when TLS is terminated by a proxy in front of Django and that # Set to True only when TLS is terminated by a proxy in front of Django and that
# proxy is the only way in. It makes Django trust X-Forwarded-Proto, which is # proxy is the only way in. It makes Django trust X-Forwarded-Proto, which is

View file

@ -156,7 +156,7 @@ CELERY_RESULT_BACKEND=redis://localhost:6379/0
# The web app, NOT this API. Every link the backend emails is built from it: # The web app, NOT this API. Every link the backend emails is built from it:
# the magic-link sign-in URL, the customer invoice and estimate portals, the # the magic-link sign-in URL, the customer invoice and estimate portals, the
# CSAT survey. Point it at the API host and every one of those links 404s. # CSAT survey. Point it at the API host and every one of those links 404s.
FRONTEND_URL=http://localhost:5173 FRONTEND_URL=http://localhost:5181
# Google sign-in. Without these the OAuth login flow cannot complete, which is # Google sign-in. Without these the OAuth login flow cannot complete, which is
# the only interactive way into the app. # the only interactive way into the app.
@ -164,7 +164,7 @@ GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret GOOGLE_CLIENT_SECRET=your-google-client-secret
# Where the browser calls this API from. The SvelteKit dev server by default. # Where the browser calls this API from. The SvelteKit dev server by default.
CORS_ALLOWED_ORIGINS=http://localhost:5173 CORS_ALLOWED_ORIGINS=http://localhost:5181
# This API's own public origin # This API's own public origin
DOMAIN_NAME=http://localhost:8000 DOMAIN_NAME=http://localhost:8000

View file

@ -3,7 +3,7 @@
``common.links.frontend_url`` builds all of them from it: the customer's invoice ``common.links.frontend_url`` builds all of them from it: the customer's invoice
and estimate portal links, the CSAT survey, the internal "assigned to you" and estimate portal links, the CSAT survey, the internal "assigned to you"
notifications, and, directly in ``common/tasks.py``, the magic-link sign-in URL. notifications, and, directly in ``common/tasks.py``, the magic-link sign-in URL.
It defaults to ``http://localhost:5173`` so a checkout works with no ``.env``, It defaults to ``http://localhost:5181`` so a checkout works with no ``.env``,
which means a deployment that never sets it mails customers a link to a port on which means a deployment that never sets it mails customers a link to a port on
their own machine and mails a sign-in token to one. Nothing on the server their own machine and mails a sign-in token to one. Nothing on the server
reports it: the email sends, the link is simply dead on arrival. reports it: the email sends, the link is simply dead on arrival.
@ -56,31 +56,31 @@ def _import_settings(frontend_url, env_type):
class TestALoopbackFrontendUrlIsRefusedOutsideDev: class TestALoopbackFrontendUrlIsRefusedOutsideDev:
def test_the_dev_default_fails_the_import(self): def test_the_dev_default_fails_the_import(self):
"""The exact value an operator gets by not setting the variable.""" """The exact value an operator gets by not setting the variable."""
result = _import_settings("http://localhost:5173", "production") result = _import_settings("http://localhost:5181", "production")
assert result.returncode != 0 assert result.returncode != 0
assert "FRONTEND_URL" in result.stderr assert "FRONTEND_URL" in result.stderr
def test_the_error_says_what_to_set_it_to(self): def test_the_error_says_what_to_set_it_to(self):
"""A refusal that does not say what to do next is a support ticket.""" """A refusal that does not say what to do next is a support ticket."""
result = _import_settings("http://localhost:5173", "production") result = _import_settings("http://localhost:5181", "production")
assert "https://app.example.com" in result.stderr assert "https://app.example.com" in result.stderr
def test_the_error_says_the_link_reaches_customers(self): def test_the_error_says_the_link_reaches_customers(self):
"""The operator needs to know this is outward-facing, not internal.""" """The operator needs to know this is outward-facing, not internal."""
result = _import_settings("http://localhost:5173", "production") result = _import_settings("http://localhost:5181", "production")
assert "customers" in result.stderr assert "customers" in result.stderr
def test_the_loopback_address_fails_too(self): def test_the_loopback_address_fails_too(self):
result = _import_settings("http://127.0.0.1:5173", "production") result = _import_settings("http://127.0.0.1:5181", "production")
assert result.returncode != 0 assert result.returncode != 0
def test_the_wildcard_bind_address_fails(self): def test_the_wildcard_bind_address_fails(self):
"""`0.0.0.0` is what a Docker compose file tends to carry.""" """`0.0.0.0` is what a Docker compose file tends to carry."""
result = _import_settings("http://0.0.0.0:5173", "production") result = _import_settings("http://0.0.0.0:5181", "production")
assert result.returncode != 0 assert result.returncode != 0
@ -141,7 +141,7 @@ class TestANonAbsoluteFrontendUrlIsRefusedOutsideDev:
class TestDevIsLeftAlone: class TestDevIsLeftAlone:
def test_the_dev_default_imports_cleanly_in_dev(self): def test_the_dev_default_imports_cleanly_in_dev(self):
"""In dev the loopback URL is the correct answer, not a warning.""" """In dev the loopback URL is the correct answer, not a warning."""
result = _import_settings("http://localhost:5173", "dev") result = _import_settings("http://localhost:5181", "dev")
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
assert "FRONTEND_URL" not in result.stderr assert "FRONTEND_URL" not in result.stderr

View file

@ -9,6 +9,7 @@ rule, and `Tags.name_error` the one owner of what a storable name is.
""" """
import importlib import importlib
import uuid
from types import SimpleNamespace from types import SimpleNamespace
import pytest import pytest
@ -207,8 +208,10 @@ class TestRecomputeMigration:
module.recompute_tag_slugs(global_apps, SimpleNamespace(connection=connection)) module.recompute_tag_slugs(global_apps, SimpleNamespace(connection=connection))
def _with_slug(self, org, name, slug): def _with_slug(self, org, name, slug):
# save() recomputes the slug, so store the old one with update(). # save() recomputes the slug, so store the old one with update(). The
tag = Tags.objects.create(name=f"placeholder {name}", org=org) # placeholder must fit `name`'s 50 characters: Postgres enforces the
# width and SQLite does not, so a longer one only fails on Postgres.
tag = Tags.objects.create(name=f"placeholder {uuid.uuid4().hex}", org=org)
Tags.objects.filter(pk=tag.pk).update(name=name, slug=slug) Tags.objects.filter(pk=tag.pk).update(name=name, slug=slug)
return tag return tag

View file

@ -454,7 +454,7 @@ CORS_ORIGIN_ALLOW_ALL = os.environ.get("CORS_ALLOW_ALL", "False").lower() == "tr
CORS_ALLOWED_ORIGINS = [ CORS_ALLOWED_ORIGINS = [
origin.strip() origin.strip()
for origin in os.environ.get( for origin in os.environ.get(
"CORS_ALLOWED_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173" "CORS_ALLOWED_ORIGINS", "http://localhost:5181,http://127.0.0.1:5181"
).split(",") ).split(",")
if origin.strip() if origin.strip()
] ]
@ -523,7 +523,7 @@ DOMAIN_NAME = os.environ.get("DOMAIN_NAME", "http://localhost:8000")
ORG_API_KEY_AUTH_ENABLED = os.environ.get( ORG_API_KEY_AUTH_ENABLED = os.environ.get(
"DJANGO_ORG_API_KEY_AUTH", "true" "DJANGO_ORG_API_KEY_AUTH", "true"
).strip().lower() not in ("false", "0", "no", "off") ).strip().lower() not in ("false", "0", "no", "off")
FRONTEND_URL = os.environ.get("FRONTEND_URL", "http://localhost:5173") FRONTEND_URL = os.environ.get("FRONTEND_URL", "http://localhost:5181")
# Every link this system puts in an email is built from this one value, via # Every link this system puts in an email is built from this one value, via
# `common.links.frontend_url`: the magic-link sign-in URL, the customer's # `common.links.frontend_url`: the magic-link sign-in URL, the customer's

View file

@ -101,7 +101,7 @@ services:
- ./frontend:/app - ./frontend:/app
- frontend_node_modules:/app/node_modules - frontend_node_modules:/app/node_modules
ports: ports:
- "5173:5173" - "5181:5181"
volumes: volumes:
postgres_data: postgres_data:

View file

@ -16,8 +16,8 @@ of this section's page references point at, at the same, empty prefix. Individua
under their own segment from there, for example `path("leads/", include("leads.urls", ...))`, so a under their own segment from there, for example `path("leads/", include("leads.urls", ...))`, so a
lead endpoint is `/api/leads/…`. lead endpoint is `/api/leads/…`.
Locally this is `http://localhost:8000/api/` (the default in the Docker quick start's Locally this is `http://localhost:8000/api/` (the port the Docker quick start publishes. Inside
`.env.docker`, and what `PUBLIC_DJANGO_API_URL` points the frontend at. See the compose network the frontend reaches the same API as `http://backend:8000`. See
[Docker quick start](../getting-started/docker-quick-start.md)). In a self-hosted deployment it is [Docker quick start](../getting-started/docker-quick-start.md)). In a self-hosted deployment it is
whatever host you put `DOMAIN_NAME` / `ALLOWED_HOSTS` on, still under `/api/`. whatever host you put `DOMAIN_NAME` / `ALLOWED_HOSTS` on, still under `/api/`.

View file

@ -66,11 +66,11 @@ pnpm install
pnpm run dev pnpm run dev
``` ```
`pnpm run dev` starts the Vite dev server on `http://localhost:5173`. `frontend/vite.config.js` `pnpm run dev` starts the Vite dev server on `http://localhost:5181`. `frontend/vite.config.js`
configures no dev-server proxy, there is no `server.proxy` entry in it, so the browser calls the configures no dev-server proxy, there is no `server.proxy` entry in it, so the browser calls the
Django API directly, cross-origin: `frontend/src/lib/api.js` builds its base URL from Django API directly, cross-origin: `frontend/src/lib/api.js` builds its base URL from
`PUBLIC_DJANGO_API_URL`, defaulting to `http://localhost:8000/api` when that variable is unset. That `PUBLIC_DJANGO_API_URL`, defaulting to `http://localhost:8000/api` when that variable is unset. That
means your backend's `CORS_ALLOWED_ORIGINS` has to include `http://localhost:5173` (it does by means your backend's `CORS_ALLOWED_ORIGINS` has to include `http://localhost:5181` (it does by
default in `backend/.env.example`: see [Manual setup](../getting-started/manual-setup.md)) or every default in `backend/.env.example`: see [Manual setup](../getting-started/manual-setup.md)) or every
request from the dev server fails as a CORS error, not a 404 or connection-refused, which is easy to request from the dev server fails as a CORS error, not a 404 or connection-refused, which is easy to
misdiagnose as a backend problem. misdiagnose as a backend problem.

View file

@ -36,7 +36,7 @@ The variables `.env.docker` actually defines are:
| `DEFAULT_FROM_EMAIL`, `ADMIN_EMAIL`, `ADMIN_PASSWORD` | Console email backend (dev), and the credentials used to bootstrap a Django admin superuser. See [What you got](#what-you-got). | | `DEFAULT_FROM_EMAIL`, `ADMIN_EMAIL`, `ADMIN_PASSWORD` | Console email backend (dev), and the credentials used to bootstrap a Django admin superuser. See [What you got](#what-you-got). |
| `CORS_ALLOW_ALL` | `True` for local development. | | `CORS_ALLOW_ALL` | `True` for local development. |
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | Blank by default; Google sign-in is disabled until both are set. The redirect URI is not a setting: the frontend sends it in the request body on each call. | | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | Blank by default; Google sign-in is disabled until both are set. The redirect URI is not a setting: the frontend sends it in the request body on each call. |
| `PUBLIC_DJANGO_API_URL` | The API base URL the frontend container is built against, `http://localhost:8000`. | | `PUBLIC_DJANGO_API_URL` | The API origin the frontend's server-side code calls, `http://backend:8000`. It uses the compose service name because `localhost` inside the frontend container is the container itself. Your browser still reaches the API at `http://localhost:8000`. |
## Start the stack ## Start the stack
@ -53,7 +53,7 @@ This builds and starts six services, defined in `docker-compose.yml`:
| `backend` | built from the repo root `Dockerfile` | `8000` | | `backend` | built from the repo root `Dockerfile` | `8000` |
| `celery-worker` | same image as `backend`, runs `celery -A crm worker --loglevel=info` |. | | `celery-worker` | same image as `backend`, runs `celery -A crm worker --loglevel=info` |. |
| `celery-beat` | same image as `backend`, runs `celery -A crm beat --loglevel=info` |, | | `celery-beat` | same image as `backend`, runs `celery -A crm beat --loglevel=info` |, |
| `frontend` | built from `frontend/Dockerfile` | `5173` | | `frontend` | built from `frontend/Dockerfile` | `5181` |
`backend`, `celery-worker` and `celery-beat` all wait for `db` and `redis` to report healthy `backend`, `celery-worker` and `celery-beat` all wait for `db` and `redis` to report healthy
before starting. The `backend` container's entrypoint (`docker/backend/entrypoint.sh`) waits for before starting. The `backend` container's entrypoint (`docker/backend/entrypoint.sh`) waits for
@ -106,7 +106,7 @@ docker compose exec backend python manage.py devlogin you@example.com --org Micr
This prints an access token, a refresh token, and the `MicroPyramid` org's UUID, already bound This prints an access token, a refresh token, and the `MicroPyramid` org's UUID, already bound
into the token. See [First sign-in](first-sign-in.md) for how to use them from the frontend at into the token. See [First sign-in](first-sign-in.md) for how to use them from the frontend at
`http://localhost:5173`. `http://localhost:5181`.
## What you got ## What you got
@ -117,7 +117,7 @@ After `docker compose up --build` and one `seed_data` run, you have:
server. This compose file is for development). server. This compose file is for development).
- A Celery worker and a Celery beat scheduler, both connected to the `redis` service, running the - A Celery worker and a Celery beat scheduler, both connected to the `redis` service, running the
periodic and background tasks registered in `backend/crm/celery.py`. periodic and background tasks registered in `backend/crm/celery.py`.
- A SvelteKit frontend on `http://localhost:5173`, built against `PUBLIC_DJANGO_API_URL=http://localhost:8000`. - A SvelteKit frontend on `http://localhost:5181`, which reaches the API over the compose network at `PUBLIC_DJANGO_API_URL=http://backend:8000`.
- A Django admin superuser, created automatically by `create_default_admin` on first boot, using - A Django admin superuser, created automatically by `create_default_admin` on first boot, using
`ADMIN_EMAIL` / `ADMIN_PASSWORD` from `.env.docker` (`admin@localhost` / `admin` unless you `ADMIN_EMAIL` / `ADMIN_PASSWORD` from `.env.docker` (`admin@localhost` / `admin` unless you
override them). This account can sign in to Django's own `/admin/` site; it does not have a CRM override them). This account can sign in to Django's own `/admin/` site; it does not have a CRM

View file

@ -64,7 +64,7 @@ instead (`event.cookies.get('jwt_access')`, `('jwt_refresh')` and `('org')`), an
server-side reads `localStorage` at all. Without those cookies set, `hooks.server.js` treats the server-side reads `localStorage` at all. Without those cookies set, `hooks.server.js` treats the
request as signed out and redirects to `/login`, no matter what `localStorage` holds. request as signed out and redirects to `/login`, no matter what `localStorage` holds.
To get past the guard, open the devtools console on `http://localhost:5173`, run the printed To get past the guard, open the devtools console on `http://localhost:5181`, run the printed
`localStorage.setItem(...)` snippet, and set the matching cookies yourself: `localStorage.setItem(...)` snippet, and set the matching cookies yourself:
```js ```js

View file

@ -144,7 +144,7 @@ you're following the ports in this guide). Then start the dev server:
pnpm run dev pnpm run dev
``` ```
This serves the SvelteKit app on `http://localhost:5173` (`vite dev` under the hood, per the This serves the SvelteKit app on `http://localhost:5181` (`vite dev` under the hood, per the
`dev` script in `frontend/package.json`). `dev` script in `frontend/package.json`).
## Running all three ## Running all three

View file

@ -112,7 +112,7 @@ Pooling uses psycopg 3's `psycopg_pool` through Django's PostgreSQL backend. It
| Variable | Default | Required | Purpose | | Variable | Default | Required | Purpose |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `CORS_ALLOW_ALL` | `False` | No | Sets Django's `CORS_ORIGIN_ALLOW_ALL`. Note the name doesn't match the Django setting it controls. `"true"` (case-insensitive) allows every origin; anything else leaves it off. | | `CORS_ALLOW_ALL` | `False` | No | Sets Django's `CORS_ORIGIN_ALLOW_ALL`. Note the name doesn't match the Django setting it controls. `"true"` (case-insensitive) allows every origin; anything else leaves it off. |
| `CORS_ALLOWED_ORIGINS` | `http://localhost:5173,http://127.0.0.1:5173` | Yes, once the frontend is served from a real domain | Comma-separated list of allowed CORS origins; each entry is stripped of surrounding whitespace and empty entries are dropped. | | `CORS_ALLOWED_ORIGINS` | `http://localhost:5181,http://127.0.0.1:5181` | Yes, once the frontend is served from a real domain | Comma-separated list of allowed CORS origins; each entry is stripped of surrounding whitespace and empty entries are dropped. |
| `CSRF_TRUSTED_ORIGINS` | *(empty, no trusted origins)* | Yes, before anyone but you can reach the instance | Comma-separated list, same stripping rule as `CORS_ALLOWED_ORIGINS`. Each entry needs a scheme, e.g. `https://crm.example.com`. A bare hostname is silently useless to Django's CSRF check. | | `CSRF_TRUSTED_ORIGINS` | *(empty, no trusted origins)* | Yes, before anyone but you can reach the instance | Comma-separated list, same stripping rule as `CORS_ALLOWED_ORIGINS`. Each entry needs a scheme, e.g. `https://crm.example.com`. A bare hostname is silently useless to Django's CSRF check. |
### URLs ### URLs
@ -120,7 +120,7 @@ Pooling uses psycopg 3's `psycopg_pool` through Django's PostgreSQL backend. It
| Variable | Default | Required | Purpose | | Variable | Default | Required | Purpose |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `DOMAIN_NAME` | `http://localhost:8000` | No. Nothing reads it any more | Base URL of the **backend**. It no longer reaches a single emitted link. The four remaining reads (`invoices/tasks.py:305,482` and `invoices/api_views.py:276,436`) pass it as the `domain=` kwarg of Celery tasks whose signatures keep that parameter only so a task queued before the change still unpacks after deploy, and whose docstrings say it is ignored. Every link now comes from `FRONTEND_URL` via `common/links.py`. It used to build the internal "assigned to you" notifications as well as the customer-facing invoice, estimate and CSAT links, and every one of those was broken: the builders wrote `f"{protocol}://{domain}/..."` around a value that already carried a scheme, so the emitted link had two, and the paths are frontend routes that do not exist on the backend host. Setting this variable changes nothing; it is kept so an existing `.env` does not have to be edited. | | `DOMAIN_NAME` | `http://localhost:8000` | No. Nothing reads it any more | Base URL of the **backend**. It no longer reaches a single emitted link. The four remaining reads (`invoices/tasks.py:305,482` and `invoices/api_views.py:276,436`) pass it as the `domain=` kwarg of Celery tasks whose signatures keep that parameter only so a task queued before the change still unpacks after deploy, and whose docstrings say it is ignored. Every link now comes from `FRONTEND_URL` via `common/links.py`. It used to build the internal "assigned to you" notifications as well as the customer-facing invoice, estimate and CSAT links, and every one of those was broken: the builders wrote `f"{protocol}://{domain}/..."` around a value that already carried a scheme, so the emitted link had two, and the paths are frontend routes that do not exist on the backend host. Setting this variable changes nothing; it is kept so an existing `.env` does not have to be edited. |
| `FRONTEND_URL` | `http://localhost:5173` | **Yes**, once self-hosted under a real domain | Base URL of the SvelteKit frontend, and the single source for every link that leaves the system in an email: the welcome email and magic-link sign-in URL (`common/tasks.py`), the emailed invoice and estimate portal links, the CSAT survey link, and the "assigned to you" invoice link (all via `common.links.frontend_url`). The customer-facing ones go to people outside the organization, so leaving the `localhost` default in a real deployment emails dead links to customers. **That is now refused rather than emailed:** when `ENV_TYPE` is anything other than `dev`, `crm/settings.py` raises at import if this points at a loopback host (`localhost`, `127.0.0.1`, `0.0.0.0`, `::1`) or is not an absolute `http`/`https` URL, so the deploy fails to start instead of failing silently on somebody else's inbox. Plain HTTP against a real host is allowed, for a deployment behind its own TLS terminator. A trailing slash is stripped, so `https://app.example.com` and `https://app.example.com/` behave the same. | | `FRONTEND_URL` | `http://localhost:5181` | **Yes**, once self-hosted under a real domain | Base URL of the SvelteKit frontend, and the single source for every link that leaves the system in an email: the welcome email and magic-link sign-in URL (`common/tasks.py`), the emailed invoice and estimate portal links, the CSAT survey link, and the "assigned to you" invoice link (all via `common.links.frontend_url`). The customer-facing ones go to people outside the organization, so leaving the `localhost` default in a real deployment emails dead links to customers. **That is now refused rather than emailed:** when `ENV_TYPE` is anything other than `dev`, `crm/settings.py` raises at import if this points at a loopback host (`localhost`, `127.0.0.1`, `0.0.0.0`, `::1`) or is not an absolute `http`/`https` URL, so the deploy fails to start instead of failing silently on somebody else's inbox. Plain HTTP against a real host is allowed, for a deployment behind its own TLS terminator. A trailing slash is stripped, so `https://app.example.com` and `https://app.example.com/` behave the same. |
### Google OAuth ### Google OAuth

View file

@ -24,7 +24,7 @@ requirements in [Production deployment](production-deploy.md).
| `backend` | built from the repo-root `Dockerfile` | `8000` | The Django API, served via `manage.py runserver` in this compose file (see the warning above). | | `backend` | built from the repo-root `Dockerfile` | `8000` | The Django API, served via `manage.py runserver` in this compose file (see the warning above). |
| `celery-worker` | same image as `backend` |, (no published port) | Runs `celery -A crm worker --loglevel=info`; executes background tasks such as outbound email and RLS-context-aware jobs in `backend/common/tasks.py`. | | `celery-worker` | same image as `backend` |, (no published port) | Runs `celery -A crm worker --loglevel=info`; executes background tasks such as outbound email and RLS-context-aware jobs in `backend/common/tasks.py`. |
| `celery-beat` | same image as `backend` |, (no published port) | Runs `celery -A crm beat --loglevel=info`; fires the periodic tasks registered in `backend/crm/celery.py` (recurring invoices, overdue/expiry checks, SLA scanning, cleanup jobs) on their schedules. | | `celery-beat` | same image as `backend` |, (no published port) | Runs `celery -A crm beat --loglevel=info`; fires the periodic tasks registered in `backend/crm/celery.py` (recurring invoices, overdue/expiry checks, SLA scanning, cleanup jobs) on their schedules. |
| `frontend` | built from `frontend/Dockerfile` | `5173` | The SvelteKit app, served via `pnpm dev --host 0.0.0.0`, also a dev server, not a production build. | | `frontend` | built from `frontend/Dockerfile` | `5181` | The SvelteKit app, served via `pnpm dev --host 0.0.0.0`, also a dev server, not a production build. |
`backend`, `celery-worker` and `celery-beat` each declare `depends_on: db (service_healthy), redis `backend`, `celery-worker` and `celery-beat` each declare `depends_on: db (service_healthy), redis
(service_healthy)`, using the `pg_isready` and `redis-cli ping` healthchecks defined on those two (service_healthy)`, using the `pg_isready` and `redis-cli ping` healthchecks defined on those two
@ -47,7 +47,8 @@ superuser role. See [PostgreSQL and RLS](postgresql-and-rls.md) for why the app
as this role), `CELERY_BROKER_URL`/`CELERY_RESULT_BACKEND` (both pointing at `redis`), as this role), `CELERY_BROKER_URL`/`CELERY_RESULT_BACKEND` (both pointing at `redis`),
`DEFAULT_FROM_EMAIL`/`ADMIN_EMAIL`/`ADMIN_PASSWORD`, `CORS_ALLOW_ALL=True`, `DEFAULT_FROM_EMAIL`/`ADMIN_EMAIL`/`ADMIN_PASSWORD`, `CORS_ALLOW_ALL=True`,
`GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET` (blank. Google sign-in stays disabled until both `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET` (blank. Google sign-in stays disabled until both
are set), and `PUBLIC_DJANGO_API_URL=http://localhost:8000` for the frontend build. are set), and `PUBLIC_DJANGO_API_URL=http://backend:8000`, the compose service name the
frontend's server-side code uses to reach the API.
## Running it ## Running it

View file

@ -11,7 +11,7 @@ Create an OAuth 2.0 Client ID of type "Web application" in the Google Cloud Cons
URI it needs authorized is built by the frontend's login page URI it needs authorized is built by the frontend's login page
(`frontend/src/routes/(no-layout)/login/+page.server.js`) as `${GOOGLE_LOGIN_DOMAIN}/login`; (`frontend/src/routes/(no-layout)/login/+page.server.js`) as `${GOOGLE_LOGIN_DOMAIN}/login`;
`GOOGLE_LOGIN_DOMAIN` is a **frontend** environment variable (`frontend/.env.example` defaults it to `GOOGLE_LOGIN_DOMAIN` is a **frontend** environment variable (`frontend/.env.example` defaults it to
`http://localhost:5173`), so the value to register with Google is `http://localhost:5173/login` for `http://localhost:5181`), so the value to register with Google is `http://localhost:5181/login` for
local development, or `https://<your-frontend-host>/login` in production. There is no separate local development, or `https://<your-frontend-host>/login` in production. There is no separate
redirect registration needed for the mobile flow. It doesn't use a redirect at all (see redirect registration needed for the mobile flow. It doesn't use a redirect at all (see
[Mobile ID tokens](#mobile-id-tokens) below). [Mobile ID tokens](#mobile-id-tokens) below).

View file

@ -1,6 +1,6 @@
# Google OAuth (client_id only - secret is on backend) # Google OAuth (client_id only - secret is on backend)
GOOGLE_CLIENT_ID="your-google-client-id-here" GOOGLE_CLIENT_ID="your-google-client-id-here"
GOOGLE_LOGIN_DOMAIN="http://localhost:5173" GOOGLE_LOGIN_DOMAIN="http://localhost:5181"
# Django API URL (PUBLIC_ prefix makes it available in both server and browser) # Django API URL (PUBLIC_ prefix makes it available in both server and browser)
PUBLIC_DJANGO_API_URL=http://localhost:8000 PUBLIC_DJANGO_API_URL=http://localhost:8000

View file

@ -40,7 +40,7 @@ RUN pnpm install --frozen-lockfile
# The compose target. Source and node_modules arrive as mounts, so this stage # The compose target. Source and node_modules arrive as mounts, so this stage
# deliberately copies no source of its own. # deliberately copies no source of its own.
FROM deps AS dev FROM deps AS dev
EXPOSE 5173 EXPOSE 5181
CMD ["pnpm", "dev", "--host", "0.0.0.0"] CMD ["pnpm", "dev", "--host", "0.0.0.0"]

View file

@ -108,7 +108,7 @@ JWT_SECRET="<your-generated-secret>"
# Google OAuth (Optional) # Google OAuth (Optional)
GOOGLE_CLIENT_ID="" GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET="" GOOGLE_CLIENT_SECRET=""
GOOGLE_LOGIN_DOMAIN="http://localhost:5173" GOOGLE_LOGIN_DOMAIN="http://localhost:5181"
``` ```
5. **Set up the database:** 5. **Set up the database:**