@django-stack/cli 0.8.1 → 0.9.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
@@ -158,7 +158,7 @@ export declare const STACK_OPTIONS: readonly [{
158
158
  readonly value: "on";
159
159
  readonly label: "On";
160
160
  readonly hint: "in the admin and by email";
161
- readonly generated: false;
161
+ readonly generated: true;
162
162
  }, {
163
163
  readonly value: "off";
164
164
  readonly label: "Off";
@@ -233,6 +233,28 @@ export declare const STACK_OPTIONS: readonly [{
233
233
  readonly hint: "any S3-compatible service; credentials later";
234
234
  readonly generated: true;
235
235
  }];
236
+ }, {
237
+ readonly key: "errors";
238
+ readonly flag: "--errors";
239
+ readonly message: "Error reports";
240
+ readonly description: "How you hear about errors in production";
241
+ readonly bits: 2;
242
+ readonly values: readonly [{
243
+ readonly value: "email";
244
+ readonly label: "Email";
245
+ readonly hint: "to the superusers, and in the admin";
246
+ readonly generated: true;
247
+ }, {
248
+ readonly value: "sentry";
249
+ readonly label: "Sentry";
250
+ readonly hint: "or GlitchTip, with SENTRY_DSN";
251
+ readonly generated: false;
252
+ }, {
253
+ readonly value: "off";
254
+ readonly label: "Off";
255
+ readonly hint: "the logs only";
256
+ readonly generated: true;
257
+ }];
236
258
  }];
237
259
  type Options = (typeof STACK_OPTIONS)[number];
238
260
  type StackKey = Options["key"];
@@ -272,6 +294,10 @@ interface ScaffoldOptions {
272
294
  localS3?: boolean;
273
295
  /** Page analytics with the bundled Umami (the `analytics` profile). */
274
296
  analytics?: boolean;
297
+ /** Content Studio's notifications app; on by default. */
298
+ notifications?: boolean;
299
+ /** How errors are reported in production; by email by default. */
300
+ errors?: Stack["errors"];
275
301
  /** The cache backend; the database by default. */
276
302
  cache?: Stack["cache"];
277
303
  /** The task queue; Huey where the cache is by default. */
@@ -295,6 +321,11 @@ interface ScaffoldResult {
295
321
  }
296
322
  /** The `.env` values of a cache and a task queue: CACHE_URL, TASKS_URL and TASK_WORKERS. */
297
323
  export declare function cacheEnv(stack: Pick<Stack, "cache" | "tasks">): Record<string, string>;
324
+ /**
325
+ * Adds or removes Content Studio's notifications app in the text of
326
+ * `backend/config/settings.py`. Throws when it can't find where it goes.
327
+ */
328
+ export declare function setNotificationsApp(settingsText: string, on: boolean): string;
298
329
  /** Whether `.env` values use the Valkey service (the `valkey` profile). */
299
330
  export declare function usesValkey(env: Record<string, string | undefined>): boolean;
300
331
  /** A valid project name from a directory name: `My Site` becomes `my-site`. */
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export { P as DEFAULT_STACK, i as FRONTENDS, F as NAMED_PRESETS, L as STACK_OPTIONS, v as VERSION, o as cacheEnv, z as decodePreset, B as encodePreset, b as generateProjectSecrets, x as generateSecret, l as normalizeDatabaseUrl, et as parseEnv, V as pendingChoices, n as productionEnv, H as resolvePreset, Y as runDoctor, d as scaffold, nt as setEnvValues, p as toProjectName, h as usesValkey, r as validateDomain, g as validateProjectName } from "./production-CGkI7oNe.js";
1
+ export { F as DEFAULT_STACK, i as FRONTENDS, I as NAMED_PRESETS, R as STACK_OPTIONS, y as VERSION, o as cacheEnv, B as decodePreset, V as encodePreset, x as generateProjectSecrets, S as generateSecret, l as normalizeDatabaseUrl, tt as parseEnv, H as pendingChoices, n as productionEnv, U as resolvePreset, X as runDoctor, d as scaffold, rt as setEnvValues, f as setNotificationsApp, m as toProjectName, g as usesValkey, r as validateDomain, _ as validateProjectName } from "./production-M2SsuuBb.js";
@@ -582,7 +582,7 @@ const STACK_OPTIONS = [
582
582
  value: "on",
583
583
  label: "On",
584
584
  hint: "in the admin and by email",
585
- generated: false
585
+ generated: true
586
586
  }, {
587
587
  value: "off",
588
588
  label: "Off",
@@ -665,6 +665,33 @@ const STACK_OPTIONS = [
665
665
  hint: "any S3-compatible service; credentials later",
666
666
  generated: true
667
667
  }]
668
+ },
669
+ {
670
+ key: "errors",
671
+ flag: "--errors",
672
+ message: "Error reports",
673
+ description: "How you hear about errors in production",
674
+ bits: 2,
675
+ values: [
676
+ {
677
+ value: "email",
678
+ label: "Email",
679
+ hint: "to the superusers, and in the admin",
680
+ generated: true
681
+ },
682
+ {
683
+ value: "sentry",
684
+ label: "Sentry",
685
+ hint: "or GlitchTip, with SENTRY_DSN",
686
+ generated: false
687
+ },
688
+ {
689
+ value: "off",
690
+ label: "Off",
691
+ hint: "the logs only",
692
+ generated: true
693
+ }
694
+ ]
668
695
  }
669
696
  ];
670
697
  /**
@@ -945,7 +972,8 @@ function projectManifest(project) {
945
972
  function projectStack(project) {
946
973
  const fromEnv = {
947
974
  analytics: project.profiles.includes("analytics") ? "umami" : "off",
948
- ...project.env.CACHE_URL === void 0 ? {} : cacheStack(project.env)
975
+ ...project.env.CACHE_URL === void 0 ? {} : cacheStack(project.env),
976
+ ...notificationsStack(project)
949
977
  };
950
978
  const { preset } = projectManifest(project);
951
979
  if (preset) try {
@@ -960,6 +988,12 @@ function projectStack(project) {
960
988
  storage: project.profiles.includes("s3") ? "s3" : "local"
961
989
  };
962
990
  }
991
+ /** Notifications are on when the backend's INSTALLED_APPS has Content Studio's notifications app. */
992
+ function notificationsStack(project) {
993
+ const settings = join(project.directory, "backend", "config", "settings.py");
994
+ if (!existsSync(settings)) return {};
995
+ return { notifications: /^ *["']content_studio\.notifications["'],/m.test(readFileSync(settings, "utf8")) ? "on" : "off" };
996
+ }
963
997
  const REDIS = /^(redis|rediss|valkey|valkeys):/;
964
998
  /**
965
999
  * The cache and the tasks a project's `.env` runs. Projects from before 0.7
@@ -1155,6 +1189,25 @@ function cacheEnv(stack) {
1155
1189
  ...TASK_ENV[taskQueue(stack)]
1156
1190
  };
1157
1191
  }
1192
+ /** The line of Content Studio's notifications app in the backend's INSTALLED_APPS. */
1193
+ const NOTIFICATIONS_APP = " \"content_studio.notifications\", # notifications in the admin and by email\n";
1194
+ const NOTIFICATIONS_PATTERN = /^ *["']content_studio\.notifications["'],.*\n/m;
1195
+ /**
1196
+ * Adds or removes Content Studio's notifications app in the text of
1197
+ * `backend/config/settings.py`. Throws when it can't find where it goes.
1198
+ */
1199
+ function setNotificationsApp(settingsText, on) {
1200
+ const present = NOTIFICATIONS_PATTERN.test(settingsText);
1201
+ if (!on) return settingsText.replace(NOTIFICATIONS_PATTERN, "");
1202
+ if (present) return settingsText;
1203
+ const after = [
1204
+ /^ *["']content_studio\.contrib\.password_reset["'],.*\n/m,
1205
+ /^ *["']content_studio\.media_library["'],.*\n/m,
1206
+ /^ *["']content_studio["'],.*\n/m
1207
+ ].find((pattern) => pattern.test(settingsText));
1208
+ if (!after) throw new Error("backend/config/settings.py has no \"content_studio\" in INSTALLED_APPS; add \"content_studio.notifications\" there yourself.");
1209
+ return settingsText.replace(after, (line) => line + NOTIFICATIONS_APP);
1210
+ }
1158
1211
  /** Whether `.env` values use the Valkey service (the `valkey` profile). */
1159
1212
  function usesValkey(env) {
1160
1213
  return [env.CACHE_URL, env.TASKS_URL].some((url) => /^(redis|rediss|valkey|valkeys):\/\/cache[:/]/.test(url ?? ""));
@@ -1256,6 +1309,10 @@ function scaffold(options) {
1256
1309
  const rendered = render(text, values);
1257
1310
  if (rendered !== text) writeFileSync(file, rendered);
1258
1311
  }
1312
+ if (options.notifications === false) {
1313
+ const settings = join(target, "backend", "config", "settings.py");
1314
+ writeFileSync(settings, setNotificationsApp(readFileSync(settings, "utf8"), false));
1315
+ }
1259
1316
  if (options.timeZone && options.timeZone !== "UTC") {
1260
1317
  const settings = join(target, "backend", "config", "settings.py");
1261
1318
  const text = readFileSync(settings, "utf8");
@@ -1285,6 +1342,7 @@ function scaffold(options) {
1285
1342
  env = setEnvValues(env, { COMPOSE_PROFILES: profiles.join(",") });
1286
1343
  if (options.ports) env = setEnvValues(env, portValues(parseEnv(env), options.ports));
1287
1344
  if (!options.demoContent) env = setEnvValues(env, { SEED_DEMO_CONTENT: "0" });
1345
+ if (options.errors === "off") env = setEnvValues(env, { ERRORS: "off" });
1288
1346
  writeFileSync(join(target, ".env"), env, { mode: 384 });
1289
1347
  writeFileSync(join(target, ".env.example"), envExample);
1290
1348
  rmSync(join(target, "env.example"));
@@ -1441,4 +1499,4 @@ function productionEnv(devEnv, options) {
1441
1499
  todo
1442
1500
  };
1443
1501
  }
1444
- export { isDockerDown as $, projectUrls as A, encodePreset as B, RUN_ENV as C, findProjectDirectory as D, composeOutput as E, NAMED_PRESETS as F, chooseFreePorts as G, resolvePreset as H, PROMPT_ORDER as I, portValues as J, describeMove as K, STACK_OPTIONS as L, readProject as M, runningServices as N, parseProfiles as O, DEFAULT_STACK as P, dockerRunning as Q, applies as R, RUN_DOMAIN as S, compose as T, stackOption as U, pendingChoices as V, DEV_PORTS as W, checkTools as X, runDoctor as Y, dockerNotRunning as Z, validateTimeZone as _, LOCAL_S3 as a, generateProjectSecrets as b, lockBackend as c, scaffold as d, parseEnv as et, timeZones as f, validateProjectName as g, usesValkey as h, FRONTENDS as i, readLocalRun as j, projectStack as k, normalizeDatabaseUrl as l, umamiEnv as m, productionEnv as n, setEnvValues as nt, cacheEnv as o, toProjectName as p, envPorts as q, validateDomain as r, localS3 as s, missingS3 as t, quoteEnvValue as tt, relativeDirectory as u, CLI_VERSION as v, activeProject as w, generateSecret as x, PACKAGE_NAME as y, decodePreset as z };
1502
+ export { dockerRunning as $, projectStack as A, decodePreset as B, RUN_DOMAIN as C, composeOutput as D, compose as E, DEFAULT_STACK as F, DEV_PORTS as G, pendingChoices as H, NAMED_PRESETS as I, envPorts as J, chooseFreePorts as K, PROMPT_ORDER as L, readLocalRun as M, readProject as N, findProjectDirectory as O, runningServices as P, dockerNotRunning as Q, STACK_OPTIONS as R, generateSecret as S, activeProject as T, resolvePreset as U, encodePreset as V, stackOption as W, runDoctor as X, portValues as Y, checkTools as Z, validateProjectName as _, LOCAL_S3 as a, PACKAGE_NAME as b, lockBackend as c, scaffold as d, isDockerDown as et, setNotificationsApp as f, usesValkey as g, umamiEnv as h, FRONTENDS as i, projectUrls as j, parseProfiles as k, normalizeDatabaseUrl as l, toProjectName as m, productionEnv as n, quoteEnvValue as nt, cacheEnv as o, timeZones as p, describeMove as q, validateDomain as r, setEnvValues as rt, localS3 as s, missingS3 as t, parseEnv as tt, relativeDirectory as u, validateTimeZone as v, RUN_ENV as w, generateProjectSecrets as x, CLI_VERSION as y, applies as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@django-stack/cli",
3
- "version": "0.8.1",
3
+ "version": "0.9.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",
@@ -20,16 +20,15 @@ WORKDIR /app
20
20
  FROM base AS deps
21
21
  COPY pyproject.toml uv.lock* ./
22
22
  COPY vendor/ vendor/
23
- RUN --mount=type=cache,target=/root/.cache/uv \
24
- uv sync --no-dev --no-install-project
23
+ # No BuildKit cache mounts: Railway rejects them, and the layer is cached anyway.
24
+ RUN uv sync --no-dev --no-install-project --no-cache
25
25
 
26
26
  # Development runs as root, so files the container writes into the mounted
27
27
  # source (such as new migrations) work on every host. Production does not.
28
28
  # The entrypoint runs with sh, so it works without the executable bit (the
29
29
  # mounted source keeps the host's file modes).
30
30
  FROM deps AS dev
31
- RUN --mount=type=cache,target=/root/.cache/uv \
32
- uv sync --no-install-project
31
+ RUN uv sync --no-install-project --no-cache
33
32
  ENTRYPOINT ["sh", "/app/entrypoint.sh"]
34
33
  CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
35
34
 
@@ -44,4 +43,4 @@ EXPOSE 8000
44
43
  HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
45
44
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/readyz', timeout=4)"
46
45
  ENTRYPOINT ["sh", "/app/entrypoint.sh"]
47
- CMD ["gunicorn", "config.asgi:application", "--worker-class", "uvicorn_worker.UvicornWorker", "--bind", "0.0.0.0:8000", "--workers", "3", "--access-logfile", "-", "--no-control-socket"]
46
+ CMD ["gunicorn", "config.asgi:application", "--worker-class", "uvicorn_worker.UvicornWorker", "--bind", "0.0.0.0:8000", "--workers", "3", "--no-control-socket"]
@@ -38,6 +38,7 @@ INSTALLED_APPS = [
38
38
  "content_studio",
39
39
  "content_studio.media_library",
40
40
  "content_studio.contrib.password_reset",
41
+ "content_studio.notifications", # notifications in the admin and by email
41
42
  "django_stack",
42
43
  "content",
43
44
  # Last: builds the API routes once every model is exposed.
@@ -45,6 +46,7 @@ INSTALLED_APPS = [
45
46
  ]
46
47
 
47
48
  MIDDLEWARE = [
49
+ "django_stack.logs.RequestIdMiddleware", # request IDs and the access log
48
50
  "django.middleware.security.SecurityMiddleware",
49
51
  "whitenoise.middleware.WhiteNoiseMiddleware",
50
52
  "django.contrib.sessions.middleware.SessionMiddleware",
@@ -10,12 +10,16 @@ from content_studio.contrib.huey import HueyAdapter
10
10
  from content_studio.dashboard.tasks import TaskDashboard
11
11
  from content_studio.extensions import UserMenuLink
12
12
  from django_stack.analytics import UmamiProvider
13
+ from django_stack.studio import SystemLink, SystemPage
13
14
 
14
15
 
15
16
  class SiteStudio(ContentStudioSite):
16
17
  extensions = [
17
18
  TaskDashboard(adapter=HueyAdapter()),
18
19
  UserMenuLink(url="/tasks/", label="Tasks", icon="ph-bold ph-queue"),
20
+ # Superusers only: the health checks and the latest error reports.
21
+ SystemPage(),
22
+ SystemLink(),
19
23
  ]
20
24
 
21
25
  # Page analytics from the bundled Umami when UMAMI_URL is set (see
@@ -1,6 +1,10 @@
1
- from django.apps import AppConfig
1
+ from django.apps import AppConfig, apps
2
2
 
3
3
 
4
4
  class ContentConfig(AppConfig):
5
5
  name = "content"
6
6
  verbose_name = "Content"
7
+
8
+ def ready(self):
9
+ if apps.is_installed("content_studio.notifications"):
10
+ from . import notifications # noqa: F401 - registers the events
@@ -0,0 +1,36 @@
1
+ """
2
+ The notifications of the site, with Content Studio's notifications app
3
+ (`content_studio.notifications` in INSTALLED_APPS). Editors get them in
4
+ the admin and, when they leave them unread, in an email digest. Each
5
+ editor picks which events they get on their settings page.
6
+
7
+ Register your own events here, and send them with notify(); see
8
+ https://djangocontentstudio.org for the API.
9
+ """
10
+
11
+ from content_studio.notifications import notify, register_event
12
+ from django.db.models.signals import post_save
13
+ from django.dispatch import receiver
14
+ from django.utils.translation import gettext_lazy as _
15
+
16
+ from .models import FormSubmission
17
+
18
+ FORM_SUBMITTED = register_event(
19
+ "site.form_submitted",
20
+ _("Contact form messages"),
21
+ _("A visitor sent a message with the contact form."),
22
+ )
23
+
24
+
25
+ @receiver(post_save, sender=FormSubmission, dispatch_uid="content_form_submitted")
26
+ def notify_form_submitted(sender, instance, created, raw=False, **kwargs):
27
+ """Every editor who may read the messages hears about a new one."""
28
+ if created and not raw:
29
+ notify(
30
+ FORM_SUBMITTED.key,
31
+ title=_("Message from %(name)s"),
32
+ # The visitor's text goes in params: a "%" in it stays text.
33
+ body=_("%(message)s"),
34
+ params={"name": instance.name, "message": instance.message[:200]},
35
+ target=instance,
36
+ )
@@ -5,6 +5,7 @@ Run them with `pnpm test` at the project root.
5
5
  """
6
6
 
7
7
  import pytest
8
+ from django.apps import apps
8
9
  from django.conf import settings
9
10
  from rest_framework.test import APIClient
10
11
 
@@ -69,5 +70,22 @@ def test_contact_form(seeded):
69
70
  assert FormSubmission.objects.filter(email="ada@example.com").exists()
70
71
 
71
72
 
73
+ @pytest.mark.skipif(not apps.is_installed("content_studio.notifications"), reason="notifications are off")
74
+ def test_contact_form_notifies_the_editors(seeded, django_capture_on_commit_callbacks):
75
+ from content_studio.notifications.models import Notification
76
+ from django.contrib.auth import get_user_model
77
+
78
+ editor = get_user_model().objects.create_superuser("editor", "editor@example.com", "x")
79
+ with django_capture_on_commit_callbacks(execute=True):
80
+ APIClient().post(
81
+ "/api/content.formsubmission",
82
+ {"name": "Ada", "email": "ada@example.com", "message": "100% sure"},
83
+ format="json",
84
+ )
85
+ notification = Notification.objects.get(recipient=editor, event="site.form_submitted")
86
+ assert notification.get_title() == "Message from Ada"
87
+ assert notification.get_body() == "100% sure"
88
+
89
+
72
90
  def test_read_key_cannot_read_submissions(reader):
73
91
  assert reader.get("/api/content.formsubmission").status_code == 403
@@ -5,7 +5,7 @@ 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.8.1",
8
+ "django-stack[server,s3]>=0.9.0",
9
9
  "django-headless[schema]>=1.1.1",
10
10
  "django-content-studio[images,huey]>=1.11.0",
11
11
  ]
@@ -27,6 +27,12 @@
27
27
  header ?X-Frame-Options "DENY"
28
28
  header -Server
29
29
 
30
+ # A request ID for every request: Django and the frontend add it to
31
+ # their log lines and return it in X-Request-ID, and Caddy's own log
32
+ # line carries it as request_id. One sent by the client is replaced.
33
+ request_header X-Request-ID {http.request.uuid}
34
+ log_append request_id {http.request.uuid}
35
+
30
36
  # Uploads, when media is stored on the server instead of S3. No scripts
31
37
  # run from uploaded files, whatever their type.
32
38
  handle_path /media/* {
@@ -59,6 +65,6 @@
59
65
 
60
66
  log {
61
67
  output stdout
62
- format console
68
+ format json
63
69
  }
64
70
  }
@@ -26,6 +26,14 @@ x-hardened: &hardened
26
26
  cap_drop:
27
27
  - ALL
28
28
 
29
+ # Every service: Docker keeps at most 5 log files of 10 MB, so logs never
30
+ # fill the disk.
31
+ x-logging: &logging
32
+ driver: json-file
33
+ options:
34
+ max-size: 10m
35
+ max-file: "5"
36
+
29
37
  x-frontend-env: &frontend-env
30
38
  SITE_URL: ${SITE_URL}
31
39
  API_URL: http://backend:8000/api
@@ -60,6 +68,14 @@ x-backend-health: &backend-health
60
68
  start_period: 60s
61
69
  retries: 30
62
70
 
71
+ # The worker stamps a heartbeat every minute; unhealthy when it stopped.
72
+ x-worker-health: &worker-health
73
+ test: ["CMD", "python", "manage.py", "worker_alive"]
74
+ interval: 30s
75
+ timeout: 20s
76
+ start_period: 120s
77
+ retries: 3
78
+
63
79
  services:
64
80
  # Development ------------------------------------------------------------
65
81
 
@@ -78,6 +94,7 @@ services:
78
94
  - "127.0.0.1:${BACKEND_PORT:-8000}:8000"
79
95
  depends_on: *backend-depends
80
96
  healthcheck: *backend-health
97
+ logging: *logging
81
98
  restart: unless-stopped
82
99
 
83
100
  worker:
@@ -96,6 +113,8 @@ services:
96
113
  depends_on:
97
114
  backend:
98
115
  condition: service_healthy
116
+ healthcheck: *worker-health
117
+ logging: *logging
99
118
  restart: unless-stopped
100
119
 
101
120
  frontend:
@@ -112,6 +131,7 @@ services:
112
131
  depends_on:
113
132
  backend:
114
133
  condition: service_healthy
134
+ logging: *logging
115
135
  restart: unless-stopped
116
136
 
117
137
  mailpit:
@@ -119,6 +139,7 @@ services:
119
139
  image: axllent/mailpit:v1.27
120
140
  ports:
121
141
  - "127.0.0.1:${MAIL_PORT:-8025}:8025"
142
+ logging: *logging
122
143
  restart: unless-stopped
123
144
 
124
145
  # A local S3 service, so development runs the same storage code as
@@ -132,6 +153,7 @@ services:
132
153
  - s3data:/data
133
154
  ports:
134
155
  - "127.0.0.1:${S3_PORT:-8333}:8333"
156
+ logging: *logging
135
157
  restart: unless-stopped
136
158
 
137
159
  # Production -------------------------------------------------------------
@@ -160,6 +182,7 @@ services:
160
182
  resources:
161
183
  limits:
162
184
  memory: 768M
185
+ logging: *logging
163
186
  restart: unless-stopped
164
187
 
165
188
  worker-prod:
@@ -185,13 +208,13 @@ services:
185
208
  depends_on:
186
209
  backend-prod:
187
210
  condition: service_healthy
188
- healthcheck:
189
- disable: true
211
+ healthcheck: *worker-health
190
212
  deploy:
191
213
  replicas: ${TASK_WORKERS:-1}
192
214
  resources:
193
215
  limits:
194
216
  memory: 512M
217
+ logging: *logging
195
218
  restart: unless-stopped
196
219
 
197
220
  frontend-prod:
@@ -212,6 +235,7 @@ services:
212
235
  resources:
213
236
  limits:
214
237
  memory: 512M
238
+ logging: *logging
215
239
  restart: unless-stopped
216
240
 
217
241
  # The only service with public ports: HTTPS for SITE_URL with a
@@ -244,6 +268,7 @@ services:
244
268
  resources:
245
269
  limits:
246
270
  memory: 256M
271
+ logging: *logging
247
272
  restart: unless-stopped
248
273
 
249
274
  # Shared -----------------------------------------------------------------
@@ -276,6 +301,7 @@ services:
276
301
  resources:
277
302
  limits:
278
303
  memory: 512M
304
+ logging: *logging
279
305
  restart: unless-stopped
280
306
 
281
307
  db:
@@ -298,6 +324,7 @@ services:
298
324
  resources:
299
325
  limits:
300
326
  memory: 1G
327
+ logging: *logging
301
328
  restart: unless-stopped
302
329
 
303
330
  # Valkey (the valkey profile), for CACHE_URL or TASKS_URL redis://cache.
@@ -318,6 +345,7 @@ services:
318
345
  resources:
319
346
  limits:
320
347
  memory: 256M
348
+ logging: *logging
321
349
  restart: unless-stopped
322
350
 
323
351
  volumes:
@@ -114,3 +114,20 @@ BACKUP_TIME=03:00
114
114
  BACKUP_KEEP=14
115
115
  BACKUP_MEDIA=1
116
116
  BACKUP_S3_BUCKET=
117
+ # Requested after each daily backup: the URL of a Healthchecks.io check or
118
+ # an Uptime Kuma push monitor, which alerts you when the pings stop.
119
+ BACKUP_PING_URL=
120
+
121
+ # Error reports (with DJANGO_DEBUG=0): each new error of the backend, the
122
+ # worker or a backup is mailed to ERROR_EMAIL (comma separated) or, when it
123
+ # is empty, to the superusers, and shows in the admin's notifications. The
124
+ # same error is mailed once an hour at most. ERRORS=off turns it off.
125
+ ERRORS=email
126
+ ERROR_EMAIL=
127
+
128
+ # Logs: one JSON object per line in production, plain text in development
129
+ # (LOG_FORMAT=json or plain). LOG_ACCESS=0 turns off Django's access log.
130
+ # See the logs page of the DjangoStack docs.
131
+ # LOG_FORMAT=json
132
+ # LOG_LEVEL=INFO
133
+ # LOG_ACCESS=1
@@ -9,8 +9,8 @@ ENV ASTRO_TELEMETRY_DISABLED=1
9
9
  FROM base AS deps
10
10
  COPY package.json package-lock.json* ./
11
11
  COPY vendor/ vendor/
12
- RUN --mount=type=cache,target=/root/.npm \
13
- if [ -f package-lock.json ]; then npm ci; else npm install; fi
12
+ # No BuildKit cache mounts: Railway rejects them, and the layer is cached anyway.
13
+ RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
14
14
 
15
15
  FROM deps AS dev
16
16
  EXPOSE 3000
@@ -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.8.1",
19
+ "@django-stack/utils": "^0.9.0",
20
20
  "astro": "^7.4.5"
21
21
  },
22
22
  "devDependencies": {
@@ -9,8 +9,8 @@ ENV NEXT_TELEMETRY_DISABLED=1
9
9
  FROM base AS deps
10
10
  COPY package.json package-lock.json* ./
11
11
  COPY vendor/ vendor/
12
- RUN --mount=type=cache,target=/root/.npm \
13
- if [ -f package-lock.json ]; then npm ci; else npm install; fi
12
+ # No BuildKit cache mounts: Railway rejects them, and the layer is cached anyway.
13
+ RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
14
14
 
15
15
  FROM deps AS dev
16
16
  EXPOSE 3000
@@ -13,9 +13,9 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "@django-headless/client": "^1.1.1",
16
- "@django-stack/next": "^0.8.1",
17
- "@django-stack/react": "^0.8.1",
18
- "@django-stack/utils": "^0.8.1",
16
+ "@django-stack/next": "^0.9.0",
17
+ "@django-stack/react": "^0.9.0",
18
+ "@django-stack/utils": "^0.9.0",
19
19
  "next": "^16.4.4",
20
20
  "react": "^19.3.1",
21
21
  "react-dom": "^19.3.1",
@@ -9,8 +9,8 @@ ENV NUXT_TELEMETRY_DISABLED=1
9
9
  FROM base AS deps
10
10
  COPY package.json package-lock.json* ./
11
11
  COPY vendor/ vendor/
12
- RUN --mount=type=cache,target=/root/.npm \
13
- if [ -f package-lock.json ]; then npm ci --ignore-scripts; else npm install --ignore-scripts; fi
12
+ # No BuildKit cache mounts: Railway rejects them, and the layer is cached anyway.
13
+ RUN if [ -f package-lock.json ]; then npm ci --ignore-scripts; else npm install --ignore-scripts; fi
14
14
 
15
15
  FROM deps AS dev
16
16
  EXPOSE 3000
@@ -15,8 +15,8 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "@django-headless/client": "^1.1.1",
18
- "@django-stack/utils": "^0.8.1",
19
- "@django-stack/vue": "^0.8.1",
18
+ "@django-stack/utils": "^0.9.0",
19
+ "@django-stack/vue": "^0.9.0",
20
20
  "nuxt": "^4.6.3",
21
21
  "nuxt-security": "^2.7.0",
22
22
  "vue": "^3.5.44",
@@ -8,8 +8,8 @@ WORKDIR /app
8
8
  FROM base AS deps
9
9
  COPY package.json package-lock.json* ./
10
10
  COPY vendor/ vendor/
11
- RUN --mount=type=cache,target=/root/.npm \
12
- if [ -f package-lock.json ]; then npm ci; else npm install; fi
11
+ # No BuildKit cache mounts: Railway rejects them, and the layer is cached anyway.
12
+ RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
13
13
 
14
14
  FROM deps AS dev
15
15
  EXPOSE 3000
@@ -19,8 +19,8 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@django-headless/client": "^1.1.1",
22
- "@django-stack/svelte": "^0.8.1",
23
- "@django-stack/utils": "^0.8.1"
22
+ "@django-stack/svelte": "^0.9.0",
23
+ "@django-stack/utils": "^0.9.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@sveltejs/adapter-node": "^6.0.1",