@django-stack/cli 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -93,7 +93,7 @@ export declare const STACK_OPTIONS: readonly [{
93
93
  readonly value: "umami";
94
94
  readonly label: "Umami";
95
95
  readonly hint: "built in, private, no cookies";
96
- readonly generated: false;
96
+ readonly generated: true;
97
97
  }, {
98
98
  readonly value: "off";
99
99
  readonly label: "Off";
@@ -235,6 +235,8 @@ interface ScaffoldOptions {
235
235
  databaseUrl?: string;
236
236
  /** Store uploads in the local S3 service in development, like S3 in production. */
237
237
  localS3?: boolean;
238
+ /** Page analytics with the bundled Umami (the `analytics` profile). */
239
+ analytics?: boolean;
238
240
  /** A DjangoStack monorepo to take the packages from instead of the registries (for development). */
239
241
  link?: string;
240
242
  /** The time zone of the backend, such as Europe/Amsterdam. UTC by default. */
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export { f as DEFAULT_STACK, t as FRONTENDS, p as NAMED_PRESETS, m as STACK_OPTIONS, d as VERSION, g as decodePreset, _ as encodePreset, C as generateProjectSecrets, w as generateSecret, i as normalizeDatabaseUrl, D as parseEnv, v as pendingChoices, x as productionEnv, y as resolvePreset, T as runDoctor, o as scaffold, O as setEnvValues, c as toProjectName, S as validateDomain, l as validateProjectName } from "./scaffold-DbgGYEsc.js";
1
+ export { h as DEFAULT_STACK, r as FRONTENDS, g as NAMED_PRESETS, _ as STACK_OPTIONS, m as VERSION, y as decodePreset, b as encodePreset, w as generateProjectSecrets, T as generateSecret, o as normalizeDatabaseUrl, O as parseEnv, x as pendingChoices, t as productionEnv, S as resolvePreset, E as runDoctor, c as scaffold, k as setEnvValues, u as toProjectName, n as validateDomain, f as validateProjectName } from "./production-DIj9yRcg.js";
@@ -2,7 +2,7 @@ import { createRequire } from "node:module";
2
2
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { join, relative } from "node:path";
4
4
  import { execFileSync } from "node:child_process";
5
- import { randomBytes } from "node:crypto";
5
+ import { randomBytes, randomUUID } from "node:crypto";
6
6
  import { fileURLToPath } from "node:url";
7
7
  // src/env.ts
8
8
  /** Reads a .env file: KEY=value lines, comments and blank lines ignored. */
@@ -283,69 +283,9 @@ function generateProjectSecrets() {
283
283
  api_read: generateSecret(32),
284
284
  preview: generateSecret(32),
285
285
  revalidate: generateSecret(32),
286
- admin: generateSecret(12)
287
- };
288
- }
289
- // src/production.ts
290
- function validateDomain(domain) {
291
- if (!/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(domain)) return "Enter a domain name such as example.com, without https://.";
292
- }
293
- /**
294
- * The .env for a server, from a project's development .env: the prod
295
- * profile, HTTPS on the domain, and fresh secrets, so nothing from the
296
- * laptop works on the server.
297
- */
298
- function productionEnv(devEnv, options) {
299
- const domainError = validateDomain(options.domain);
300
- if (domainError) throw new Error(domainError);
301
- const current = parseEnv(devEnv);
302
- const secrets = generateProjectSecrets();
303
- const url = `https://${options.domain.toLowerCase()}`;
304
- const todo = [];
305
- const profiles = (current.COMPOSE_PROFILES ?? "dev,db").split(",").map((profile) => profile.trim()).filter((profile) => profile && profile !== "dev" && profile !== "s3");
306
- profiles.unshift("prod");
307
- const values = {
308
- COMPOSE_PROFILES: [...new Set(profiles)].join(","),
309
- SITE_URL: url,
310
- BACKEND_URL: url,
311
- DJANGO_DEBUG: "0",
312
- DJANGO_SECRET_KEY: secrets.django ?? "",
313
- API_SECRET_KEY: secrets.api ?? "",
314
- API_READ_KEY: secrets.api_read ?? "",
315
- PREVIEW_SECRET: secrets.preview ?? "",
316
- REVALIDATE_SECRET: secrets.revalidate ?? "",
317
- ADMIN_PASSWORD: secrets.admin ?? "",
318
- DEFAULT_FROM_EMAIL: `${current.PROJECT_NAME ?? "site"} <noreply@${options.domain.toLowerCase()}>`
319
- };
320
- if (profiles.includes("db")) {
321
- const user = current.POSTGRES_USER || "app";
322
- const name = current.POSTGRES_DB || "app";
323
- values.POSTGRES_PASSWORD = secrets.postgres ?? "";
324
- values.DATABASE_URL = `postgres://${user}:${secrets.postgres}@db:5432/${name}`;
325
- }
326
- if (options.emailUrl) values.EMAIL_URL = options.emailUrl;
327
- else if (!current.EMAIL_URL || current.EMAIL_URL.includes("mailpit")) {
328
- values.EMAIL_URL = "smtp+tls://user:password@smtp.example.com:587";
329
- todo.push("Set EMAIL_URL to your SMTP server, so password resets and notifications are sent.");
330
- }
331
- if ((current.S3_ENDPOINT_URL ?? "").includes("s3:8333")) {
332
- Object.assign(values, {
333
- S3_BUCKET: "",
334
- S3_ENDPOINT_URL: "",
335
- S3_REGION: "",
336
- S3_ACCESS_KEY_ID: "",
337
- S3_SECRET_ACCESS_KEY: "",
338
- S3_PUBLIC_URL: "",
339
- S3_CREATE_BUCKET: "0"
340
- });
341
- todo.push("Set the S3_* variables to your S3 service, or leave them empty to keep uploads on the server.");
342
- }
343
- if (!current.ADMIN_EMAIL || current.ADMIN_EMAIL.endsWith("@example.com")) todo.push("Set ADMIN_EMAIL to your address.");
344
- if (current.SEED_DEMO_CONTENT === "1") todo.push("Set SEED_DEMO_CONTENT=0 unless the site should start with the demo pages and posts.");
345
- return {
346
- text: setEnvValues(devEnv, values),
347
- adminPassword: values.ADMIN_PASSWORD ?? "",
348
- todo
286
+ admin: generateSecret(12),
287
+ umami_app: generateSecret(32),
288
+ umami_admin: generateSecret(24)
349
289
  };
350
290
  }
351
291
  // src/stack.ts
@@ -420,7 +360,7 @@ const STACK_OPTIONS = [
420
360
  value: "umami",
421
361
  label: "Umami",
422
362
  hint: "built in, private, no cookies",
423
- generated: false
363
+ generated: true
424
364
  }, {
425
365
  value: "off",
426
366
  label: "Off",
@@ -642,6 +582,22 @@ const CLI_VERSION = createRequire(import.meta.url)("../package.json").version;
642
582
  // src/scaffold.ts
643
583
  /** The frontends this version of the CLI generates. */
644
584
  const FRONTENDS = stackOption("frontend").values.filter((value) => value.generated).map((value) => value.value);
585
+ /** Umami's database URL: the project's database, with Umami's tables in the `umami` schema. */
586
+ function umamiDatabaseUrl(databaseUrl) {
587
+ const url = new URL(databaseUrl);
588
+ url.searchParams.set("schema", "umami");
589
+ return url.toString();
590
+ }
591
+ /** The `.env` values of the bundled Umami, from the project's database URL and secrets. */
592
+ function umamiEnv(databaseUrl, secrets) {
593
+ return {
594
+ UMAMI_URL: "http://umami:3000",
595
+ UMAMI_WEBSITE_ID: randomUUID(),
596
+ UMAMI_ADMIN_PASSWORD: secrets.umami_admin ?? "",
597
+ UMAMI_APP_SECRET: secrets.umami_app ?? "",
598
+ UMAMI_DATABASE_URL: umamiDatabaseUrl(databaseUrl)
599
+ };
600
+ }
645
601
  /** The local S3 service of the `s3` profile. It accepts any credentials. */
646
602
  const LOCAL_S3 = {
647
603
  S3_BUCKET: "media",
@@ -749,6 +705,10 @@ function scaffold(options) {
749
705
  profiles.push("s3");
750
706
  env = setEnvValues(env, LOCAL_S3);
751
707
  }
708
+ if (options.analytics) {
709
+ profiles.push("analytics");
710
+ env = setEnvValues(env, umamiEnv(parseEnv(env).DATABASE_URL ?? "", secrets));
711
+ }
752
712
  env = setEnvValues(env, { COMPOSE_PROFILES: profiles.join(",") });
753
713
  if (!options.demoContent) env = setEnvValues(env, { SEED_DEMO_CONTENT: "0" });
754
714
  writeFileSync(join(target, ".env"), env, { mode: 384 });
@@ -830,4 +790,71 @@ function relativeDirectory(directory) {
830
790
  const path = relative(process.cwd(), directory) || ".";
831
791
  return path.startsWith("..") ? directory : path;
832
792
  }
833
- export { generateProjectSecrets as C, parseEnv as D, checkTools as E, setEnvValues as O, validateDomain as S, runDoctor as T, encodePreset as _, relativeDirectory as a, stackOption as b, toProjectName as c, CLI_VERSION as d, DEFAULT_STACK as f, decodePreset as g, applies as h, normalizeDatabaseUrl as i, validateProjectName as l, STACK_OPTIONS as m, LOCAL_S3 as n, scaffold as o, NAMED_PRESETS as p, lockBackend as r, timeZones as s, FRONTENDS as t, validateTimeZone as u, pendingChoices as v, generateSecret as w, productionEnv as x, resolvePreset as y };
793
+ // src/production.ts
794
+ function validateDomain(domain) {
795
+ if (!/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(domain)) return "Enter a domain name such as example.com, without https://.";
796
+ }
797
+ /**
798
+ * The .env for a server, from a project's development .env: the prod
799
+ * profile, HTTPS on the domain, and fresh secrets, so nothing from the
800
+ * laptop works on the server.
801
+ */
802
+ function productionEnv(devEnv, options) {
803
+ const domainError = validateDomain(options.domain);
804
+ if (domainError) throw new Error(domainError);
805
+ const current = parseEnv(devEnv);
806
+ const secrets = generateProjectSecrets();
807
+ const url = `https://${options.domain.toLowerCase()}`;
808
+ const todo = [];
809
+ const profiles = (current.COMPOSE_PROFILES ?? "dev,db").split(",").map((profile) => profile.trim()).filter((profile) => profile && profile !== "dev" && profile !== "s3");
810
+ profiles.unshift("prod");
811
+ const values = {
812
+ COMPOSE_PROFILES: [...new Set(profiles)].join(","),
813
+ SITE_URL: url,
814
+ BACKEND_URL: url,
815
+ DJANGO_DEBUG: "0",
816
+ DJANGO_SECRET_KEY: secrets.django ?? "",
817
+ API_SECRET_KEY: secrets.api ?? "",
818
+ API_READ_KEY: secrets.api_read ?? "",
819
+ PREVIEW_SECRET: secrets.preview ?? "",
820
+ REVALIDATE_SECRET: secrets.revalidate ?? "",
821
+ ADMIN_PASSWORD: secrets.admin ?? "",
822
+ DEFAULT_FROM_EMAIL: `${current.PROJECT_NAME ?? "site"} <noreply@${options.domain.toLowerCase()}>`
823
+ };
824
+ if (profiles.includes("db")) {
825
+ const user = current.POSTGRES_USER || "app";
826
+ const name = current.POSTGRES_DB || "app";
827
+ values.POSTGRES_PASSWORD = secrets.postgres ?? "";
828
+ values.DATABASE_URL = `postgres://${user}:${secrets.postgres}@db:5432/${name}`;
829
+ }
830
+ if (current.UMAMI_URL) {
831
+ values.UMAMI_ADMIN_PASSWORD = secrets.umami_admin ?? "";
832
+ values.UMAMI_APP_SECRET = secrets.umami_app ?? "";
833
+ values.UMAMI_DATABASE_URL = umamiDatabaseUrl(values.DATABASE_URL ?? current.DATABASE_URL ?? "");
834
+ }
835
+ if (options.emailUrl) values.EMAIL_URL = options.emailUrl;
836
+ else if (!current.EMAIL_URL || current.EMAIL_URL.includes("mailpit")) {
837
+ values.EMAIL_URL = "smtp+tls://user:password@smtp.example.com:587";
838
+ todo.push("Set EMAIL_URL to your SMTP server, so password resets and notifications are sent.");
839
+ }
840
+ if ((current.S3_ENDPOINT_URL ?? "").includes("s3:8333")) {
841
+ Object.assign(values, {
842
+ S3_BUCKET: "",
843
+ S3_ENDPOINT_URL: "",
844
+ S3_REGION: "",
845
+ S3_ACCESS_KEY_ID: "",
846
+ S3_SECRET_ACCESS_KEY: "",
847
+ S3_PUBLIC_URL: "",
848
+ S3_CREATE_BUCKET: "0"
849
+ });
850
+ todo.push("Set the S3_* variables to your S3 service, or leave them empty to keep uploads on the server.");
851
+ }
852
+ if (!current.ADMIN_EMAIL || current.ADMIN_EMAIL.endsWith("@example.com")) todo.push("Set ADMIN_EMAIL to your address.");
853
+ if (current.SEED_DEMO_CONTENT === "1") todo.push("Set SEED_DEMO_CONTENT=0 unless the site should start with the demo pages and posts.");
854
+ return {
855
+ text: setEnvValues(devEnv, values),
856
+ adminPassword: values.ADMIN_PASSWORD ?? "",
857
+ todo
858
+ };
859
+ }
860
+ export { stackOption as C, checkTools as D, runDoctor as E, parseEnv as O, resolvePreset as S, generateSecret as T, STACK_OPTIONS as _, lockBackend as a, encodePreset as b, scaffold as c, umamiEnv as d, validateProjectName as f, NAMED_PRESETS as g, DEFAULT_STACK as h, LOCAL_S3 as i, setEnvValues as k, timeZones as l, CLI_VERSION as m, validateDomain as n, normalizeDatabaseUrl as o, validateTimeZone as p, FRONTENDS as r, relativeDirectory as s, productionEnv as t, toProjectName as u, applies as v, generateProjectSecrets as w, pendingChoices as x, decodePreset as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@django-stack/cli",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "The production-ready Django, batteries included stack. Create and manage DjangoStack projects: a Django backend, a frontend, Postgres and Docker Compose in one command.",
5
5
  "license": "MIT",
6
6
  "author": "Leon van der Grient",
@@ -5,9 +5,11 @@ https://djangocontentstudio.org/docs/extensions for the hooks and extensions.
5
5
  """
6
6
 
7
7
  from content_studio.admin import ContentStudioSite
8
+ from content_studio.analytics import AnalyticsExtension, AnalyticsLink, AnalyticsPage
8
9
  from content_studio.contrib.huey import HueyAdapter
9
10
  from content_studio.dashboard.tasks import TaskDashboard
10
11
  from content_studio.extensions import UserMenuLink
12
+ from django_stack.analytics import UmamiProvider
11
13
 
12
14
 
13
15
  class SiteStudio(ContentStudioSite):
@@ -16,5 +18,12 @@ class SiteStudio(ContentStudioSite):
16
18
  UserMenuLink(url="/tasks/", label="Tasks", icon="ph-bold ph-queue"),
17
19
  ]
18
20
 
21
+ # Page analytics from the bundled Umami when UMAMI_URL is set (see
22
+ # .env): an Analytics page in the main menu. content/admin.py shows the
23
+ # numbers of each page and post. See
24
+ # https://djangocontentstudio.org/docs/analytics.
25
+ if UmamiProvider.is_configured():
26
+ extensions += [AnalyticsExtension(provider=UmamiProvider()), AnalyticsPage(), AnalyticsLink()]
27
+
19
28
 
20
29
  admin_site = SiteStudio()
@@ -5,6 +5,7 @@ The studios of the content models: how they look in Content Studio at
5
5
 
6
6
  from content_studio import register
7
7
  from content_studio.admin import ModelStudio
8
+ from content_studio.analytics import Analytics
8
9
  from content_studio.form import ActivityLog, Field, FormSet
9
10
  from django_stack.studio import PreviewButton
10
11
 
@@ -15,6 +16,10 @@ PAGE_PREVIEW = PreviewButton(component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a01"
15
16
  POST_PREVIEW = PreviewButton(component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a02")
16
17
  PAGE_ACTIVITY = ActivityLog(component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a03")
17
18
  POST_ACTIVITY = ActivityLog(component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a04")
19
+ # The visitors of the page or post, from the analytics (see config/studio.py).
20
+ # Hidden while analytics are off.
21
+ PAGE_ANALYTICS = Analytics(path_field="path", component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a05")
22
+ POST_ANALYTICS = Analytics(path_field="slug", path_prefix="/blog/", component_id="8d0f6a52-3c51-4a8e-9a2b-6f1f0c8f2a06")
18
23
  SEO = FormSet(
19
24
  title="Search and sharing",
20
25
  description="Leave empty to use the title and the site settings.",
@@ -37,7 +42,7 @@ class PageStudio(ModelStudio):
37
42
  FormSet(title="Body", fields=["body"]),
38
43
  SEO,
39
44
  ]
40
- edit_sidebar = ["status", "published_at", PAGE_PREVIEW, "order", PAGE_ACTIVITY]
45
+ edit_sidebar = ["status", "published_at", PAGE_PREVIEW, "order", PAGE_ANALYTICS, PAGE_ACTIVITY]
41
46
 
42
47
 
43
48
  @register(Post)
@@ -55,7 +60,16 @@ class PostStudio(ModelStudio):
55
60
  FormSet(title="Body", fields=["body"]),
56
61
  SEO,
57
62
  ]
58
- edit_sidebar = ["status", "published_at", POST_PREVIEW, "author", "categories", "tags", POST_ACTIVITY]
63
+ edit_sidebar = [
64
+ "status",
65
+ "published_at",
66
+ POST_PREVIEW,
67
+ "author",
68
+ "categories",
69
+ "tags",
70
+ POST_ANALYTICS,
71
+ POST_ACTIVITY,
72
+ ]
59
73
 
60
74
 
61
75
  @register(Category, Tag)
@@ -5,7 +5,8 @@
5
5
  # first admin user and, with SEED_DEMO_CONTENT=1, add demo content to an
6
6
  # empty site. DJANGO_CHECK_DEPLOY=1 (production): stop when Django's deploy
7
7
  # checks find an error. S3_CREATE_BUCKET=1 (the development S3 service):
8
- # create the media bucket.
8
+ # create the media bucket. UMAMI_URL set (the analytics profile): set up
9
+ # Umami; when that fails the site still starts, without analytics.
9
10
  set -eu
10
11
 
11
12
  python manage.py wait_for_db
@@ -22,6 +23,9 @@ if [ "${DJANGO_MIGRATE:-0}" = "1" ]; then
22
23
  if [ "${SEED_DEMO_CONTENT:-0}" = "1" ]; then
23
24
  python manage.py seed_content --if-empty
24
25
  fi
26
+ if [ -n "${UMAMI_URL:-}" ]; then
27
+ python manage.py setup_umami || echo "Umami is not set up, so the admin shows no analytics. See the error above." >&2
28
+ fi
25
29
  fi
26
30
 
27
31
  exec "$@"
@@ -5,9 +5,9 @@ description = "The Django backend of {{project_name}}."
5
5
  requires-python = ">=3.12"
6
6
  dependencies = [
7
7
  "Django>=6.1,<6.2",
8
- "django-stack[server,s3]>=0.4.0",
8
+ "django-stack[server,s3]>=0.6.0",
9
9
  "django-headless[schema]>=1.1.1",
10
- "django-content-studio[images,huey]>=1.8.0",
10
+ "django-content-studio[images,huey]>=1.11.0",
11
11
  ]
12
12
 
13
13
  [dependency-groups]
@@ -41,7 +41,8 @@ Run them at the project root. They need Docker; Node is only needed for the `npm
41
41
 
42
42
  In production (`COMPOSE_PROFILES=prod,db`) the services are `backend-prod`,
43
43
  `worker-prod` (which also runs the daily backup), `frontend-prod` and
44
- `proxy`; their containers are
44
+ `proxy`, with `umami` for page analytics when the `analytics` profile is on;
45
+ their containers are
45
46
  read-only, so a change goes through the code and a new image, never an edit
46
47
  in a running container.
47
48
 
@@ -1,7 +1,8 @@
1
1
  # {{project_name}}: the reverse proxy of the prod profile.
2
2
  #
3
3
  # One domain: Django serves /admin, /api and /static; uploads on the server's
4
- # disk are served from /media; the frontend serves everything else. Caddy
4
+ # disk are served from /media; Umami's tracker from /_a/; the frontend
5
+ # serves everything else. Caddy
5
6
  # gets and renews the HTTPS certificate for SITE_URL by itself.
6
7
  #
7
8
  # Behind a platform's proxy (Coolify, Dokploy), CADDY_ADDRESS=:80 serves
@@ -35,6 +36,18 @@
35
36
  file_server
36
37
  }
37
38
 
39
+ # Page analytics: only Umami's tracker and its page views, never its
40
+ # interface or API. Without the analytics profile these answer 502.
41
+ handle_path /_a/* {
42
+ @tracker path /script.js /api/send
43
+ handle @tracker {
44
+ reverse_proxy umami:3000
45
+ }
46
+ handle {
47
+ respond 404
48
+ }
49
+ }
50
+
38
51
  @django path /admin /admin/* /api /api/* /static/*
39
52
  handle @django {
40
53
  reverse_proxy backend:8000
@@ -51,6 +51,15 @@ site with drafts shown, for an hour.
51
51
  When an editor saves, Django tells the frontend (`REVALIDATE_URL`), which
52
52
  empties its page cache, so the change shows on the next visit.
53
53
 
54
+ ## Analytics
55
+
56
+ When `COMPOSE_PROFILES` in `.env` has `analytics`, the `umami` service
57
+ counts page views without cookies, and the admin shows them under
58
+ **Analytics**. Umami's own interface is not served: only its tracker and the
59
+ page views pass through the site at `/_a/`. To turn it off, remove
60
+ `analytics` from `COMPOSE_PROFILES` and empty `UMAMI_URL`. See
61
+ [Page analytics](https://dwarsbit.github.io/django-stack/docs/analytics).
62
+
54
63
  ## Deploy
55
64
 
56
65
  On a server with Docker and a domain pointing at it:
@@ -6,7 +6,8 @@
6
6
  # prod,db `docker compose up -d` on a server: production images behind
7
7
  # Caddy on ports 80 and 443, with automatic HTTPS and daily backups.
8
8
  # Leave out `db` to use an external Postgres through DATABASE_URL, and add
9
- # `s3` in development for a local S3 service (see .env).
9
+ # `s3` in development for a local S3 service (see .env). `analytics` runs
10
+ # Umami for page analytics.
10
11
  #
11
12
  # The frontend only receives the variables it needs: never the full-access
12
13
  # API key or the database password.
@@ -31,6 +32,9 @@ x-frontend-env: &frontend-env
31
32
  PREVIEW_SECRET: ${PREVIEW_SECRET}
32
33
  REVALIDATE_SECRET: ${REVALIDATE_SECRET}
33
34
  MEDIA_ORIGINS: ${S3_PUBLIC_URL:-${S3_ENDPOINT_URL:-}}
35
+ # Page analytics: the tracker loads from /_a/ on the site when set.
36
+ UMAMI_WEBSITE_ID: ${UMAMI_WEBSITE_ID:-}
37
+ UMAMI_URL: ${UMAMI_URL:-}
34
38
 
35
39
  x-backend-depends: &backend-depends
36
40
  db:
@@ -41,6 +45,10 @@ x-backend-depends: &backend-depends
41
45
  s3:
42
46
  condition: service_started
43
47
  required: false
48
+ # The backend sets Umami up on start; it waits for it by itself.
49
+ umami:
50
+ condition: service_started
51
+ required: false
44
52
 
45
53
  x-backend-health: &backend-health
46
54
  test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/readyz', timeout=4)"]
@@ -232,6 +240,36 @@ services:
232
240
 
233
241
  # Shared -----------------------------------------------------------------
234
242
 
243
+ # Page analytics (the analytics profile). Umami's interface and API stay on
244
+ # the internal network: Caddy, and the frontend's dev server, pass on only
245
+ # the tracker and the page views at /_a/, and the admin reads the numbers
246
+ # with UMAMI_ADMIN_PASSWORD. Its tables are in the umami schema of the
247
+ # database.
248
+ umami:
249
+ profiles: [analytics]
250
+ image: umamisoftware/umami:3.4
251
+ <<: *hardened
252
+ environment:
253
+ DATABASE_URL: ${UMAMI_DATABASE_URL:-}
254
+ APP_SECRET: ${UMAMI_APP_SECRET:-}
255
+ DISABLE_TELEMETRY: "1"
256
+ DISABLE_UPDATES: "1"
257
+ depends_on:
258
+ db:
259
+ condition: service_healthy
260
+ required: false
261
+ healthcheck:
262
+ test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/heartbeat').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
263
+ interval: 5s
264
+ timeout: 5s
265
+ start_period: 60s
266
+ retries: 30
267
+ deploy:
268
+ resources:
269
+ limits:
270
+ memory: 512M
271
+ restart: unless-stopped
272
+
235
273
  db:
236
274
  profiles: [db]
237
275
  image: postgres:17-alpine
@@ -8,7 +8,7 @@ PROJECT_NAME={{project_name}}
8
8
  # dev,db development on this machine
9
9
  # prod,db production on a server
10
10
  # Leave out `db` when DATABASE_URL points to an external Postgres; add `s3`
11
- # in development for the local S3 service.
11
+ # in development for the local S3 service. `analytics` runs Umami.
12
12
  COMPOSE_PROFILES=dev,db
13
13
 
14
14
  # The public URL of the site. In development the frontend's dev server also
@@ -71,6 +71,20 @@ S3_ACCESS_KEY_ID=
71
71
  S3_SECRET_ACCESS_KEY=
72
72
  S3_PUBLIC_URL=
73
73
 
74
+ # Page analytics: Umami, in the `analytics` profile. It runs on the internal
75
+ # network only: its tracker and the page views pass through /_a/ on the
76
+ # site, and the admin shows the numbers under Analytics. Umami keeps its
77
+ # tables in the `umami` schema of the database (UMAMI_DATABASE_URL is
78
+ # DATABASE_URL with ?schema=umami), so backups include them. The backend
79
+ # replaces Umami's default admin password with UMAMI_ADMIN_PASSWORD and
80
+ # creates the website UMAMI_WEBSITE_ID on start. To turn analytics off,
81
+ # remove `analytics` from COMPOSE_PROFILES and empty UMAMI_URL.
82
+ UMAMI_URL=
83
+ UMAMI_WEBSITE_ID=
84
+ UMAMI_ADMIN_PASSWORD=
85
+ UMAMI_APP_SECRET=
86
+ UMAMI_DATABASE_URL=
87
+
74
88
  # Backups (prod profile): a daily pg_dump at BACKUP_TIME (UTC) and, when
75
89
  # uploads are on disk (no S3_BUCKET) and BACKUP_MEDIA=1, an archive of the
76
90
  # media volume; the newest BACKUP_KEEP of each kept in the backups volume.
@@ -4,6 +4,8 @@ import { defineConfig } from "astro/config";
4
4
 
5
5
  // The Django backend: the frontend container reaches it on the Compose network.
6
6
  const backend = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:8000";
7
+ // Umami, for page analytics: only its tracker and page views, under /_a/.
8
+ const umami = process.env.UMAMI_URL || "http://umami:3000";
7
9
 
8
10
  export default defineConfig({
9
11
  site: process.env.SITE_URL ?? "http://localhost:3000",
@@ -17,6 +19,8 @@ export default defineConfig({
17
19
  security: {
18
20
  csp: {
19
21
  directives: ["default-src 'self'", "base-uri 'self'", "form-action 'self'", "object-src 'none'"],
22
+ // Scripts from this site, such as the analytics tracker at /_a/script.js.
23
+ scriptDirective: { resources: ["'self'"] },
20
24
  // Inline style attributes, such as the focal point of an image.
21
25
  styleDirective: { resources: ["'self'", { resource: "'unsafe-inline'", kind: "attribute" }] },
22
26
  },
@@ -27,12 +31,15 @@ export default defineConfig({
27
31
  allowedHosts: ["frontend"],
28
32
  // In development, serve Django's paths from the same origin as the site,
29
33
  // like the reverse proxy does in production.
30
- proxy: Object.fromEntries(
31
- ["/admin", "/api", "/media", "/static"].map((path) => [
32
- path,
33
- { target: backend, changeOrigin: false },
34
- ]),
35
- ),
34
+ proxy: {
35
+ ...Object.fromEntries(
36
+ ["/admin", "/api", "/media", "/static"].map((path) => [
37
+ path,
38
+ { target: backend, changeOrigin: false },
39
+ ]),
40
+ ),
41
+ "^/_a/(script\\.js|api/send)$": { target: umami, rewrite: (path) => path.slice("/_a".length) },
42
+ },
36
43
  },
37
44
  },
38
45
  });
@@ -16,7 +16,7 @@
16
16
  "@astrojs/node": "^11.1.9",
17
17
  "@astrojs/rss": "^4.0.19",
18
18
  "@django-headless/client": "^1.1.1",
19
- "@django-stack/utils": "^0.4.0",
19
+ "@django-stack/utils": "^0.6.0",
20
20
  "astro": "^7.4.5"
21
21
  },
22
22
  "devDependencies": {
@@ -20,6 +20,8 @@ const [settings, mainMenu, footerMenu] = await Promise.all([
20
20
  ]);
21
21
  const preview = Astro.cookies.has(PREVIEW_COOKIE);
22
22
  const path = Astro.url.pathname;
23
+ // Page analytics: Umami's tracker, served from this site at /_a/.
24
+ const analytics = process.env.UMAMI_WEBSITE_ID;
23
25
  ---
24
26
 
25
27
  <!doctype html>
@@ -30,6 +32,7 @@ const path = Astro.url.pathname;
30
32
  <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
31
33
  <link rel="alternate" type="application/rss+xml" title={settings?.site_name ?? "RSS"} href="/rss.xml" />
32
34
  <Seo page={page} settings={settings} type={type} />
35
+ {analytics && <script is:inline defer src="/_a/script.js" data-website-id={analytics} />}
33
36
  </head>
34
37
  <body>
35
38
  {
@@ -13,9 +13,9 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "@django-headless/client": "^1.1.1",
16
- "@django-stack/next": "^0.4.0",
17
- "@django-stack/react": "^0.4.0",
18
- "@django-stack/utils": "^0.4.0",
16
+ "@django-stack/next": "^0.6.0",
17
+ "@django-stack/react": "^0.6.0",
18
+ "@django-stack/utils": "^0.6.0",
19
19
  "next": "^16.4.4",
20
20
  "react": "^19.3.1",
21
21
  "react-dom": "^19.3.1",
@@ -2,6 +2,7 @@ import "./globals.css";
2
2
  import { Image, RichText } from "@django-stack/react";
3
3
  import { linkHref } from "@django-stack/utils";
4
4
  import type { Metadata } from "next";
5
+ import Script from "next/script";
5
6
  import { NavLink } from "@/components/NavLink";
6
7
  import { getContent, previewToken, SITE_URL } from "@/lib/api";
7
8
 
@@ -64,6 +65,14 @@ export default async function RootLayout({ children }: { children: React.ReactNo
64
65
  </nav>
65
66
  </div>
66
67
  </footer>
68
+ {/* Page analytics: Umami's tracker, served from this site at /_a/. */}
69
+ {process.env.UMAMI_WEBSITE_ID ? (
70
+ <Script
71
+ src="/_a/script.js"
72
+ data-website-id={process.env.UMAMI_WEBSITE_ID}
73
+ strategy="afterInteractive"
74
+ />
75
+ ) : null}
67
76
  </body>
68
77
  </html>
69
78
  );
@@ -7,6 +7,9 @@ const dev = process.env.NODE_ENV === "development";
7
7
  // development, this proxy does, so the site and the admin share an origin.
8
8
  const BACKEND_PATHS = /^\/(admin|api|media|static)(\/|$)/;
9
9
  const backend = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:8000";
10
+ // Umami's tracker and page views, for page analytics; Caddy passes them on in production.
11
+ const TRACKER_PATHS = /^\/_a\/(script\.js|api\/send)$/;
12
+ const umami = process.env.UMAMI_URL || "http://umami:3000";
10
13
  const media = mediaOrigins();
11
14
 
12
15
  export function proxy(request: NextRequest) {
@@ -14,6 +17,9 @@ export function proxy(request: NextRequest) {
14
17
  if (dev && BACKEND_PATHS.test(pathname)) {
15
18
  return NextResponse.rewrite(new URL(pathname + search, backend));
16
19
  }
20
+ if (dev && TRACKER_PATHS.test(pathname)) {
21
+ return NextResponse.rewrite(new URL(pathname.slice("/_a".length) + search, umami));
22
+ }
17
23
 
18
24
  // A fresh nonce for every page: Next adds it to the scripts it renders.
19
25
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
@@ -1,5 +1,7 @@
1
1
  // The Django backend: the frontend container reaches it on the Compose network.
2
2
  const backend = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:8000";
3
+ // Umami, for page analytics: only its tracker and page views, under /_a/.
4
+ const umami = process.env.UMAMI_URL || "http://umami:3000";
3
5
 
4
6
  export default defineNuxtConfig({
5
7
  compatibilityDate: "2026-11-01",
@@ -26,12 +28,16 @@ export default defineNuxtConfig({
26
28
  // In development, serve Django's paths from the same origin as the site,
27
29
  // like the reverse proxy does in production.
28
30
  $development: {
29
- routeRules: Object.fromEntries(
30
- ["/admin", "/api", "/media", "/static"].map((path) => [
31
- `${path}/**`,
32
- { proxy: `${backend}${path}/**` },
33
- ]),
34
- ),
31
+ routeRules: {
32
+ ...Object.fromEntries(
33
+ ["/admin", "/api", "/media", "/static"].map((path) => [
34
+ `${path}/**`,
35
+ { proxy: `${backend}${path}/**` },
36
+ ]),
37
+ ),
38
+ "/_a/script.js": { proxy: `${umami}/script.js` },
39
+ "/_a/api/send": { proxy: `${umami}/api/send` },
40
+ },
35
41
  },
36
42
  // Security headers, with a Content-Security-Policy on every page in
37
43
  // production: scripts and styles only from this site, with a nonce for
@@ -15,8 +15,8 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "@django-headless/client": "^1.1.1",
18
- "@django-stack/utils": "^0.4.0",
19
- "@django-stack/vue": "^0.4.0",
18
+ "@django-stack/utils": "^0.6.0",
19
+ "@django-stack/vue": "^0.6.0",
20
20
  "nuxt": "^4.6.3",
21
21
  "nuxt-security": "^2.7.0",
22
22
  "vue": "^3.5.44",