build-wagtail 0.1.0__tar.gz

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.
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.3
2
+ Name: build-wagtail
3
+ Version: 0.1.0
4
+ Summary: Dockerise a Wagtail project in place with one command.
5
+ Keywords: wagtail,django,docker,deployment
6
+ Author: Kailash Nadh
7
+ Author-email: Kailash Nadh <kailash@nadh.in>
8
+ Classifier: Environment :: Console
9
+ Classifier: Framework :: Django
10
+ Classifier: Framework :: Wagtail
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: System :: Installation/Setup
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+
16
+ # build-wagtail
17
+
18
+ Dockerise a Wagtail project in place with one command. It generates a
19
+ Dockerfile, settings overrides and a `compose.yml`, copies the site's database
20
+ and media into `./data`, and builds the image. The app runs on
21
+ [granian](https://github.com/emmett-framework/granian).
22
+
23
+ Needs Docker with the compose plugin. The tool itself has no dependencies.
24
+
25
+ ## Install
26
+
27
+ ```shell
28
+ uv tool install build-wagtail # or: pipx install build-wagtail
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ Run it from the Wagtail project root (where `manage.py` is):
34
+
35
+ ```shell
36
+ build-wagtail example.org,www.example.org
37
+ docker compose up -d
38
+ docker compose exec web python manage.py createsuperuser
39
+ ```
40
+
41
+ The first hostname is canonical. The site is served on `127.0.0.1:8000`; put a
42
+ reverse proxy in front of it for TLS. By default the host's proxy is expected to
43
+ serve `/static` and `/media` from `./data`, and the tool prints an example Caddy
44
+ config for that. With `--caddy`, a Caddy container serves them instead.
45
+
46
+ | Option | Default | |
47
+ |-------------------|-------------------------------------------------|----------------------------------------------------|
48
+ | `--port` | `8000` | Localhost port the site is served on |
49
+ | `--caddy` | off | Run Caddy in a container for static/media |
50
+ | `--workers` | `3` | granian workers |
51
+ | `--python` | `.python-version`, else `3.13` | Python version of the image |
52
+ | `--settings` | from `manage.py`, or `<project>.settings.production` | Settings module to build on |
53
+ | `--no-build` | off | Write the files but skip `docker compose build` |
54
+
55
+ The project needs a `uv.lock`, `pyproject.toml` or `requirements.txt`.
56
+
57
+ ## What it writes
58
+
59
+ - `.env`: secret key, hostnames and the container user. Created once, never overwritten.
60
+ - `data/`: the live database, media and collected static files. Seeded once, never overwritten. Back it up.
61
+ - `.wagtail-docker/` and `compose.yml`: regenerated on every run. Options aren't
62
+ stored, so pass the same ones each time.
@@ -0,0 +1,47 @@
1
+ # build-wagtail
2
+
3
+ Dockerise a Wagtail project in place with one command. It generates a
4
+ Dockerfile, settings overrides and a `compose.yml`, copies the site's database
5
+ and media into `./data`, and builds the image. The app runs on
6
+ [granian](https://github.com/emmett-framework/granian).
7
+
8
+ Needs Docker with the compose plugin. The tool itself has no dependencies.
9
+
10
+ ## Install
11
+
12
+ ```shell
13
+ uv tool install build-wagtail # or: pipx install build-wagtail
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ Run it from the Wagtail project root (where `manage.py` is):
19
+
20
+ ```shell
21
+ build-wagtail example.org,www.example.org
22
+ docker compose up -d
23
+ docker compose exec web python manage.py createsuperuser
24
+ ```
25
+
26
+ The first hostname is canonical. The site is served on `127.0.0.1:8000`; put a
27
+ reverse proxy in front of it for TLS. By default the host's proxy is expected to
28
+ serve `/static` and `/media` from `./data`, and the tool prints an example Caddy
29
+ config for that. With `--caddy`, a Caddy container serves them instead.
30
+
31
+ | Option | Default | |
32
+ |-------------------|-------------------------------------------------|----------------------------------------------------|
33
+ | `--port` | `8000` | Localhost port the site is served on |
34
+ | `--caddy` | off | Run Caddy in a container for static/media |
35
+ | `--workers` | `3` | granian workers |
36
+ | `--python` | `.python-version`, else `3.13` | Python version of the image |
37
+ | `--settings` | from `manage.py`, or `<project>.settings.production` | Settings module to build on |
38
+ | `--no-build` | off | Write the files but skip `docker compose build` |
39
+
40
+ The project needs a `uv.lock`, `pyproject.toml` or `requirements.txt`.
41
+
42
+ ## What it writes
43
+
44
+ - `.env`: secret key, hostnames and the container user. Created once, never overwritten.
45
+ - `data/`: the live database, media and collected static files. Seeded once, never overwritten. Back it up.
46
+ - `.wagtail-docker/` and `compose.yml`: regenerated on every run. Options aren't
47
+ stored, so pass the same ones each time.
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "build-wagtail"
3
+ version = "0.1.0"
4
+ description = "Dockerise a Wagtail project in place with one command."
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ dependencies = []
8
+ keywords = [
9
+ "wagtail",
10
+ "django",
11
+ "docker",
12
+ "deployment",
13
+ ]
14
+ classifiers = [
15
+ "Environment :: Console",
16
+ "Framework :: Django",
17
+ "Framework :: Wagtail",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: System :: Installation/Setup",
20
+ ]
21
+
22
+ [[project.authors]]
23
+ name = "Kailash Nadh"
24
+ email = "kailash@nadh.in"
25
+
26
+ [project.scripts]
27
+ build-wagtail = "build_wagtail:main"
28
+
29
+ [build-system]
30
+ requires = ["uv_build>=0.8,<0.14"]
31
+ build-backend = "uv_build"
@@ -0,0 +1,23 @@
1
+ [project]
2
+ name = "build-wagtail"
3
+ version = "0.1.0"
4
+ description = "Dockerise a Wagtail project in place with one command."
5
+ readme = "README.md"
6
+ authors = [{ name = "Kailash Nadh", email = "kailash@nadh.in" }]
7
+ requires-python = ">=3.9"
8
+ dependencies = []
9
+ keywords = ["wagtail", "django", "docker", "deployment"]
10
+ classifiers = [
11
+ "Environment :: Console",
12
+ "Framework :: Django",
13
+ "Framework :: Wagtail",
14
+ "Programming Language :: Python :: 3",
15
+ "Topic :: System :: Installation/Setup",
16
+ ]
17
+
18
+ [project.scripts]
19
+ build-wagtail = "build_wagtail:main"
20
+
21
+ [build-system]
22
+ requires = ["uv_build>=0.8,<0.14"]
23
+ build-backend = "uv_build"
@@ -0,0 +1,396 @@
1
+ """Dockerise a Wagtail project in place.
2
+
3
+ Run it from the project root (where manage.py is). It writes .wagtail-docker/,
4
+ compose.yml and .env, copies the site's data into ./data, and builds the image.
5
+ After that, use `docker compose up -d` and `docker compose down`.
6
+
7
+ build-wagtail example.org,www.example.org
8
+ build-wagtail example.org --port 8001 --workers 4 --caddy
9
+
10
+ By default, only the app server runs in Docker, on 127.0.0.1:PORT, and the
11
+ host's reverse proxy serves /static and /media straight from ./data. With
12
+ --caddy, a Caddy container serves them and proxies the rest to the app.
13
+
14
+ Re-running is safe: .env and data/ are never overwritten, generated files are.
15
+ Hostnames are only needed on the first run, and update .env when given later.
16
+ The other options aren't stored, so pass the same ones on every run.
17
+ Needs only the Python standard library.
18
+ """
19
+
20
+ import argparse
21
+ import os
22
+ import re
23
+ import secrets
24
+ import shutil
25
+ import subprocess
26
+ import sys
27
+ from pathlib import Path
28
+
29
+ MARKER = "# Generated by build-wagtail"
30
+
31
+ # Files and directories in the project, relative to its root.
32
+ MANAGE_PY = Path("manage.py")
33
+ PYTHON_VERSION_FILE = Path(".python-version")
34
+ DB_FILE = "db.sqlite3"
35
+ MEDIA_DIR = "media"
36
+ STATIC_DIR = "static"
37
+
38
+ # What this script writes into the project.
39
+ GEN = Path(".wagtail-docker")
40
+ ENV_FILE = Path(".env")
41
+ COMPOSE_FILE = Path("compose.yml")
42
+ DATA_DIR = Path("data")
43
+
44
+ # Paths and names inside the containers.
45
+ APP_DIR = "/app"
46
+ CONTAINER_DATA_DIR = "/data"
47
+ CADDY_STATIC_DIR = "/srv/static"
48
+ CADDY_MEDIA_DIR = "/srv/media"
49
+ APP_PORT = 8000
50
+ SETTINGS_MODULE = "wagtail_docker_settings"
51
+
52
+ UV_IMAGE = "ghcr.io/astral-sh/uv:python{python}-bookworm-slim"
53
+ CADDY_IMAGE = "caddy:2.10-alpine"
54
+
55
+ DEFAULT_PORT = 8000
56
+ DEFAULT_WORKERS = 3
57
+ DEFAULT_PYTHON = "3.13"
58
+
59
+ # Containers shouldn't run as root. Used instead when the script is run by root.
60
+ FALLBACK_UID = FALLBACK_GID = 1000
61
+
62
+ # Dependency files, in order of preference, and the Dockerfile lines to install from each.
63
+ DEP_INSTALLERS = [
64
+ ("uv.lock", "COPY pyproject.toml uv.lock ./\nRUN uv sync --frozen --no-dev --no-install-project"),
65
+ ("pyproject.toml", "COPY pyproject.toml ./\nRUN uv sync --no-dev --no-install-project"),
66
+ ("requirements.txt", "COPY requirements.txt ./\nRUN uv venv && uv pip install -r requirements.txt"),
67
+ ]
68
+
69
+
70
+ def die(msg):
71
+ sys.exit(f"error: {msg}")
72
+
73
+
74
+ def parse_args():
75
+ p = argparse.ArgumentParser(description="Dockerise a Wagtail project in place. Run from the project root.")
76
+ p.add_argument("hosts", nargs="?",
77
+ help="comma-separated hostnames, the first one canonical. Required on the first run.")
78
+ p.add_argument("--port", type=int, default=DEFAULT_PORT, help="localhost port the site is served on (default: %(default)s)")
79
+ p.add_argument("--caddy", action="store_true",
80
+ help="run Caddy in a container to serve /static and /media and proxy the app")
81
+ p.add_argument("--workers", type=int, default=DEFAULT_WORKERS, help="granian workers (default: %(default)s)")
82
+ p.add_argument("--python", help=f"Python version (default: {PYTHON_VERSION_FILE}, else {DEFAULT_PYTHON})")
83
+ p.add_argument("--settings",
84
+ help="settings module to build on (default: from manage.py, or <project>.settings.production)")
85
+ p.add_argument("--no-build", action="store_true", help="write the files but don't run docker compose build")
86
+ args = p.parse_args()
87
+
88
+ if not 0 < args.port < 65536:
89
+ p.error("--port must be between 1 and 65535.")
90
+ if args.workers < 1:
91
+ p.error("--workers must be at least 1.")
92
+ return args
93
+
94
+
95
+ def find_settings(override):
96
+ """Return (project package, settings module to build on)."""
97
+ m = re.search(r"""DJANGO_SETTINGS_MODULE["'],\s*["']([^"']+)["']""", MANAGE_PY.read_text())
98
+ if not m:
99
+ die(f"couldn't find DJANGO_SETTINGS_MODULE in {MANAGE_PY}.")
100
+
101
+ settings = m.group(1)
102
+ project = settings.split(".")[0]
103
+ if not Path(project, "wsgi.py").is_file():
104
+ die(f"{project}/wsgi.py not found.")
105
+
106
+ if override:
107
+ return project, override
108
+ if Path(project, "settings", "production.py").is_file():
109
+ settings = f"{project}.settings.production"
110
+ return project, settings
111
+
112
+
113
+ def find_python(override):
114
+ if override:
115
+ return override
116
+ if PYTHON_VERSION_FILE.is_file():
117
+ return PYTHON_VERSION_FILE.read_text().split()[0]
118
+ return DEFAULT_PYTHON
119
+
120
+
121
+ def deps_step():
122
+ """Return the Dockerfile lines that install the project's dependencies."""
123
+ for name, step in DEP_INSTALLERS:
124
+ if Path(name).is_file():
125
+ return step
126
+ die(f"none of {', '.join(n for n, _ in DEP_INSTALLERS)} found.")
127
+
128
+
129
+ def read_env():
130
+ env = {}
131
+ for line in ENV_FILE.read_text().splitlines():
132
+ if "=" in line and not line.lstrip().startswith("#"):
133
+ k, v = line.split("=", 1)
134
+ env[k.strip()] = v.strip()
135
+ return env
136
+
137
+
138
+ def create_env(hosts):
139
+ if os.getuid() == 0:
140
+ uid, gid = FALLBACK_UID, FALLBACK_GID
141
+ else:
142
+ uid, gid = os.getuid(), os.getgid()
143
+
144
+ body = (
145
+ f"DJANGO_SECRET_KEY={secrets.token_urlsafe(50)}\n"
146
+ "# Comma-separated. The first one is canonical.\n"
147
+ f"DJANGO_ALLOWED_HOSTS={hosts}\n"
148
+ f"PUID={uid}\n"
149
+ f"PGID={gid}\n"
150
+ )
151
+ fd = os.open(ENV_FILE, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
152
+ with os.fdopen(fd, "w") as f:
153
+ f.write(body)
154
+ print(f"Created {ENV_FILE}")
155
+
156
+
157
+ def update_env_hosts(hosts):
158
+ text = ENV_FILE.read_text()
159
+ new, n = re.subn(r"(?m)^DJANGO_ALLOWED_HOSTS=.*$", lambda _: f"DJANGO_ALLOWED_HOSTS={hosts}", text)
160
+ if n == 0:
161
+ new = text.rstrip("\n") + f"\nDJANGO_ALLOWED_HOSTS={hosts}\n"
162
+ if new != text:
163
+ ENV_FILE.write_text(new)
164
+ print(f"Updated DJANGO_ALLOWED_HOSTS in {ENV_FILE}")
165
+
166
+
167
+ def setup_data(uid, gid):
168
+ """Seed the data dir from the project's db and media on the first run."""
169
+ (DATA_DIR / STATIC_DIR).mkdir(parents=True, exist_ok=True)
170
+
171
+ if not (DATA_DIR / DB_FILE).exists() and Path(DB_FILE).is_file():
172
+ shutil.copy2(DB_FILE, DATA_DIR / DB_FILE)
173
+ print(f"Copied {DB_FILE} to {DATA_DIR / DB_FILE}")
174
+
175
+ if not (DATA_DIR / MEDIA_DIR).exists():
176
+ if Path(MEDIA_DIR).is_dir():
177
+ shutil.copytree(MEDIA_DIR, DATA_DIR / MEDIA_DIR, symlinks=True)
178
+ print(f"Copied {MEDIA_DIR}/ to {DATA_DIR / MEDIA_DIR}/")
179
+ else:
180
+ (DATA_DIR / MEDIA_DIR).mkdir()
181
+
182
+ if os.getuid() == 0:
183
+ for p in [DATA_DIR, *DATA_DIR.rglob("*")]:
184
+ os.chown(p, uid, gid, follow_symlinks=False)
185
+
186
+
187
+ def settings_py(settings):
188
+ return f'''{MARKER}. Docker overrides on top of the project's own settings.
189
+ import os
190
+
191
+ from {settings} import * # noqa: F401,F403
192
+
193
+ DEBUG = False
194
+ SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
195
+
196
+ ALLOWED_HOSTS = os.environ["DJANGO_ALLOWED_HOSTS"].split(",")
197
+ CSRF_TRUSTED_ORIGINS = [f"https://{{h}}" for h in ALLOWED_HOSTS]
198
+ WAGTAILADMIN_BASE_URL = f"https://{{ALLOWED_HOSTS[0]}}"
199
+
200
+ # TLS is terminated by the reverse proxy in front.
201
+ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
202
+ SESSION_COOKIE_SECURE = True
203
+ CSRF_COOKIE_SECURE = True
204
+
205
+ if DATABASES["default"]["ENGINE"] == "django.db.backends.sqlite3":
206
+ DATABASES["default"]["NAME"] = "{CONTAINER_DATA_DIR}/{DB_FILE}"
207
+
208
+ MEDIA_ROOT = "{CONTAINER_DATA_DIR}/{MEDIA_DIR}"
209
+ STATIC_ROOT = "{CONTAINER_DATA_DIR}/{STATIC_DIR}"
210
+ '''
211
+
212
+
213
+ def dockerfile(python, deps, project, workers):
214
+ return f'''{MARKER}.
215
+ FROM {UV_IMAGE.format(python=python)}
216
+
217
+ WORKDIR {APP_DIR}
218
+ ENV UV_COMPILE_BYTECODE=1 \\
219
+ PYTHONDONTWRITEBYTECODE=1 \\
220
+ PYTHONUNBUFFERED=1 \\
221
+ VIRTUAL_ENV={APP_DIR}/.venv \\
222
+ PATH="{APP_DIR}/.venv/bin:$PATH" \\
223
+ DJANGO_SETTINGS_MODULE={SETTINGS_MODULE}
224
+
225
+ {deps}
226
+ RUN python -c "import granian" 2>/dev/null || uv pip install granian
227
+
228
+ COPY . .
229
+ COPY {GEN}/settings.py {SETTINGS_MODULE}.py
230
+
231
+ EXPOSE {APP_PORT}
232
+ CMD ["sh", "-c", "python manage.py migrate --noinput && python manage.py collectstatic --noinput && exec granian --interface wsgi {project}.wsgi:application --host 0.0.0.0 --port {APP_PORT} --workers {workers}"]
233
+ '''
234
+
235
+
236
+ # BuildKit picks up <Dockerfile>.dockerignore next to the Dockerfile.
237
+ DOCKERIGNORE = f"""\
238
+ .git
239
+ .venv
240
+ **/__pycache__
241
+ *.sqlite3
242
+ *.sqlite3-*
243
+ *.sqlite3.*
244
+ {MEDIA_DIR}
245
+ {STATIC_DIR}
246
+ {DATA_DIR}
247
+ backups
248
+ node_modules
249
+ {ENV_FILE}
250
+ {COMPOSE_FILE}
251
+ """
252
+
253
+ CADDYFILE = f"""{MARKER}.
254
+ {{
255
+ admin off
256
+ # Pass through X-Forwarded-Proto from the host's reverse proxy.
257
+ servers {{
258
+ trusted_proxies static private_ranges
259
+ }}
260
+ }}
261
+
262
+ :80 {{
263
+ encode zstd gzip
264
+
265
+ handle_path /static/* {{
266
+ root * {CADDY_STATIC_DIR}
267
+ header Cache-Control "public, max-age=31536000, immutable"
268
+ file_server
269
+ }}
270
+ handle_path /media/* {{
271
+ root * {CADDY_MEDIA_DIR}
272
+ file_server
273
+ }}
274
+ handle {{
275
+ reverse_proxy web:{APP_PORT}
276
+ }}
277
+ }}
278
+ """
279
+
280
+
281
+ def compose(port, caddy):
282
+ # Localhost only: Docker-published ports bypass host firewalls.
283
+ ports = f""" ports:
284
+ - "127.0.0.1:{port}:{{}}"
285
+ """
286
+ out = f"""{MARKER}. Re-run build-wagtail to change this file; secrets and hosts are in {ENV_FILE}.
287
+ services:
288
+ web:
289
+ build:
290
+ context: .
291
+ dockerfile: {GEN}/Dockerfile
292
+ restart: unless-stopped
293
+ env_file: {ENV_FILE}
294
+ user: "${{PUID}}:${{PGID}}"
295
+ volumes:
296
+ - ./{DATA_DIR}:{CONTAINER_DATA_DIR}
297
+ """
298
+ if not caddy:
299
+ return out + ports.format(APP_PORT)
300
+
301
+ return out + f"""
302
+ # Serves /static and /media, proxies the rest to granian.
303
+ caddy:
304
+ image: {CADDY_IMAGE}
305
+ restart: unless-stopped
306
+ depends_on: [web]
307
+ {ports.format(80)} volumes:
308
+ - ./{GEN}/Caddyfile:/etc/caddy/Caddyfile:ro
309
+ - ./{DATA_DIR}/{STATIC_DIR}:{CADDY_STATIC_DIR}:ro
310
+ - ./{DATA_DIR}/{MEDIA_DIR}:{CADDY_MEDIA_DIR}:ro
311
+ """
312
+
313
+
314
+ def proxy_example(host, port, caddy):
315
+ """Return an example host Caddy config for the site."""
316
+ if caddy:
317
+ return f""" {host} {{
318
+ reverse_proxy 127.0.0.1:{port}
319
+ }}"""
320
+
321
+ data = DATA_DIR.resolve()
322
+ return f""" {host} {{
323
+ encode zstd gzip
324
+
325
+ handle_path /static/* {{
326
+ root * {data / STATIC_DIR}
327
+ header Cache-Control "public, max-age=31536000, immutable"
328
+ file_server
329
+ }}
330
+ handle_path /media/* {{
331
+ root * {data / MEDIA_DIR}
332
+ file_server
333
+ }}
334
+ handle {{
335
+ reverse_proxy 127.0.0.1:{port}
336
+ }}
337
+ }}"""
338
+
339
+
340
+ def write_files(args, project, settings, python, deps):
341
+ GEN.mkdir(exist_ok=True)
342
+ (GEN / "settings.py").write_text(settings_py(settings))
343
+ (GEN / "Dockerfile").write_text(dockerfile(python, deps, project, args.workers))
344
+ (GEN / "Dockerfile.dockerignore").write_text(DOCKERIGNORE)
345
+
346
+ if args.caddy:
347
+ (GEN / "Caddyfile").write_text(CADDYFILE)
348
+
349
+ COMPOSE_FILE.write_text(compose(args.port, args.caddy))
350
+ print(f"Wrote {COMPOSE_FILE} and {GEN}/ (project: {project}, settings: {settings}, "
351
+ f"python: {python}, caddy: {'yes' if args.caddy else 'no'})")
352
+
353
+
354
+ def main():
355
+ args = parse_args()
356
+
357
+ if not MANAGE_PY.is_file():
358
+ die(f"{MANAGE_PY} not found. Run this from the Wagtail project root.")
359
+ if not args.no_build and subprocess.run(["docker", "compose", "version"], capture_output=True).returncode != 0:
360
+ die("docker compose is not available.")
361
+ if COMPOSE_FILE.exists() and not COMPOSE_FILE.read_text().startswith(MARKER):
362
+ die(f"{COMPOSE_FILE} exists and wasn't generated by this script. Move it aside first.")
363
+
364
+ project, settings = find_settings(args.settings)
365
+ python = find_python(args.python)
366
+ deps = deps_step()
367
+
368
+ if not ENV_FILE.exists():
369
+ if not args.hosts:
370
+ die("first run needs the site's hostnames, e.g. build-wagtail example.org,www.example.org")
371
+ create_env(args.hosts)
372
+ elif args.hosts:
373
+ update_env_hosts(args.hosts)
374
+ env = read_env()
375
+
376
+ setup_data(int(env["PUID"]), int(env["PGID"]))
377
+ write_files(args, project, settings, python, deps)
378
+
379
+ if not args.no_build and subprocess.run(["docker", "compose", "build"]).returncode != 0:
380
+ die("docker compose build failed.")
381
+
382
+ host = env["DJANGO_ALLOWED_HOSTS"].split(",")[0]
383
+ print(f"""
384
+ Done. Start with: docker compose up -d
385
+ Stop with: docker compose down
386
+ Admin user: docker compose exec web python manage.py createsuperuser
387
+
388
+ The site is on 127.0.0.1:{args.port}. Point your reverse proxy at it for TLS, e.g. Caddy:
389
+
390
+ {proxy_example(host, args.port, args.caddy)}
391
+
392
+ {DATA_DIR}/ now holds the live database and media. Back it up.""")
393
+
394
+
395
+ if __name__ == "__main__":
396
+ main()