This open source project offer docker that three kind of web or API service solutions by php, gunicorn, uwsgi based on nginx server. You can easily create custom configuration files for nginx using a shell script. Supports https and certbot auto-extension script. there are default security settings in the nginx config file. docker-compose allows you to easily install and operate multiple domain servers on one server. For server caches, docker-compose supports installing and connecting redis and redis-state. Anyone can install web services easily using docker and docker-compose. Af you want to use python and php service at same time, this solution can help you better.
- We provide an open source infrastructure integration solution that can easily service Python, Django, PHP, etc. using docker-compose. You can install the commercial-level customizable nginx service and redis at once, and install and manage more services at once. If you are interested, please visit Devspoon-Projects.
- preparing...
-
Support to make configuration files for each service(conf, certbot) : You can use a shell script to generate conf files for https and proxy settings in nginx. Supports a script to restart docker using crontab to complete certbot authentication of the docker container.
-
Efficiently dockerfile configuration for development and service operation : The log folder is interlocked by "volumes" in docker-compose.yml so that user can can be tracked problems even when the docker container is stopped. Webroot, nginx config, etc. are frequently modified during development so these are interlocked by "volumes"
-
Provide reverse proxy function : Multiple web and app services can be provided through one nginx with php or python and services can be provided simultaneously. A shell script is provided to easily create a proxy config file so that it can be integrated with the web UI of other services.
-
Provides easy distributed service operation method : You can use multiple web servers through proxy, and you can use multiple app servers on one web server.
-
Easy service changes using Docker-compose : In docker-compose, various configuration items are defined and commented out. By deleting comments or adjusting your desired settings, you can easily create an environment that suits your purposes.
-
log file collection : Log files for all services are stored in log/ and can be monitored even after container termination.
-
redis and ssl : Information such as configuration files, data, and keys for Redis and SSL are attached as volumes to the redis and ssl folders in docker-compose, so they can be reused when the container is terminated and restarted.
-
Ready-to-run sample apps : Django (
django_sample), FastAPI (fastapi_sample), Flask (flask_sample), and PHP (php_sample) live underwww/so each of the six stacks (gunicorn / uvicorn / uwsgi / daphne / php-7.3 / php-8.4) can be brought up immediately aftergit clone. Samples are domain-agnostic — bind tolocalhostfirst, swap to your domain when ready. -
Worker privilege drop (
www-data) : gunicorn / uvicorn / uwsgi / php-fpm workers all run aswww-data(uid 33) — the container boots as root (foruv syncetc.) but workers are dropped to least privilege. The host source tree bind-mounted at/wwwis never chowned: the only writable paths are the named volume/data(SQLite,SQLITE_PATH), which the appcommandchowns towww-data, and the celery log directories, which the celery / celery-beatcommands chown towww-databefore startup (§0.6.2). uwsgi master usesuid/gid = www-data; gunicorn arbiter stays root and forks workers via setuid; celery / celery-beat run with--uid/--gid www-data. Exception: the daphne process has no privilege-drop option and runs as root inside its container. -
Secret separation (
.env-example) : Every stack ships a tracked.env-example(compose/web-service/nginx_*/.env-example), while the actual.envis gitignored. Copy it to.env, then generate the empty secrets (DJANGO_SECRET_KEY/REDIS_PASSWORD/FLOWER_PWD) withscript/lib/django_secrets.sh(ensure_env_secrets, §0.6.1); never commit the live file.${VAR:?}checks in the compose files fail-fast if a required secret is missing. -
Bot blocker auto-update + supply-chain hardening : Integrates nginx-ultimate-bad-bot-blocker. Container cron refreshes the blocklist every 6 hours. The installer scripts themselves are pinned to a fixed commit SHA and verified with sha256 at build time — no
masterfloating reference. -
Dynamic gzip compression : All four nginx config directories (gunicorn / uvicorn / uwsgi / php; daphne reuses gunicorn's) enable
gzip onwith level 5,gzip_min_length 1024, andgzip_proxied anyfor JSON/HTML/CSS/JS/XML/SVG payloads. Proxy-passed backend responses are compressed too. Pre-compressed.gzstatic assets are served viagzip_static on.
-
No DB service : This open source does not provide DB as docker to suggest stable operation. It is recommended to install it on a real server and access it using a network, such as port 3306. We hope that this will be done for distributed services as well. We hope that this will be consider for distributed services as well.
-
Development-oriented docker service : This open source is designed for focused on development-oriented rather than perfect docker container distribution and is suitable for startups or new service development teams with frequent initial modifications and tests.
-
Considering on-premise servers : This solution is built for on-premises servers. However, since it is currently being used as a test and commercial service in OCI (Oracle Cloud Infrastructure), it can be used in environments such as AWS and GCP without problems.
The operations guide is maintained as a series under docs/operations-guide/nginx-hardening/. Start at OPS-GUIDE-001 Master Index, which holds the threat model, the priority matrix, the quarterly roadmap and the series index. Each sub-guide covers one domain and is updated independently:
| No. | Document | Area covered |
|---|---|---|
| OPS-GUIDE-001 | Master Index | Threat model, priority matrix, roadmap, common rollback, series index, review/update policy |
| OPS-GUIDE-002 | TLS / certificate operations | Certificate expiry monitoring, HSTS preload, Let's Encrypt account backup |
| OPS-GUIDE-003 | Application-layer defence | WAF (ModSecurity + OWASP CRS), fail2ban, phased CSP rollout |
| OPS-GUIDE-004 | Container / image security | Resource limits, read-only filesystem, image vulnerability scanning, SBOM/signing, egress filtering, backend isolation |
| OPS-GUIDE-005 | Observability / logs / metrics | Log rotation, observability stack, custom error pages, audit log immutability, secrets management, backup/DR |
| OPS-GUIDE-006 | Edge / network | HTTP/3, real_ip, SSL mount scope, Slowloris, CONTINUATION flood, DDoS playbook |
Each document has the same seven sections: rationale (Why) → current state → implementation steps with configuration snippets → how to verify → monitoring → rollback → common pitfalls. Put the series ID in the PR title (OPS-GUIDE-003: add WAF Phase 2 exclusion) so quarterly reviews stay traceable.
-
Make webroot folder
User have to make new folder under www path Example : /www/home_test -
Make a conf file of nginx
-
PHP service (PHP 7.3 / 8.4 dual-version)
The PHP stack ships in two parallel versions selectable per deployment:
- PHP 7.3 (legacy) —
romeoz/docker-phpfpm:7.3base, Debian multi-version paths (/etc/php/7.3/fpm/...) - PHP 8.4 (current) — official
php:8.4-fpm-bookwormbase, single-path layout (/usr/local/etc/php{,-fpm.d}/...)
Each version has its own Dockerfile, compose stack, and PHP config folder (php.ini patched for PHP 8.x removals). The nginx config folder is shared because nothing in it depends on the PHP version. Both stacks bind ports 80/443, so they cannot run simultaneously — pick one per host.
-
PHP service installation [nginx for php] (shared between 7.3 and 8.4)
In config/web-server/nginx/php There are 2 shell scripts (nginx_http_conf.sh, nginx_https_conf.sh) - nginx_http_conf.sh → sample_nginx_http.conf → conf.d/<name>_php_ng_http.conf - nginx_https_conf.sh → sample_nginx_https.conf → conf.d/<name>_php_ng_https.conf Use "chmod +x xxxx.sh" command, you activate shell script and run. then it make conf file nginx's a conf file will be in conf.d folder. HTTP output always ends with "_http". Options: "./nginx_http_conf.sh -h" (no manual path escaping needed)Shell script required informations like bellow webroot : ex -> shop_kings domain : ex -> xxxx.com portnumber : ex -> 80 appname : ex -> php-app-7.3 (for the PHP 7.3 stack) or php-app-8.4 (for the PHP 8.4 stack) → must match container_name in compose/web-service/nginx_php-<ver>/docker-compose.yml serviceport : ex -> 9000 (php-fpm listen port; same in both stacks) filename : ex -> xxxx (it's the name for nginx's conf file) -
PHP service installation [php application] (version-specific folder)
PHP 7.3 → config/app-server/php-7.3 PHP 8.4 → config/app-server/php-8.4 (php.ini is patched for PHP 8.x removals: track_errors, sql.safe_mode, session.hash_*, [Interbase], [mcrypt] sections removed; error_reporting cleaned of ~E_STRICT; session.use_only_cookies / use_trans_sid / referer_check commented out. Each patch is annotated inline with a "; [PHP 8.4] ..." comment in the file.) Each folder contains 1 shell script (php_conf.sh) that generates the pool config. Use "chmod +x xxxx.sh" command to activate, then run. It writes to pool.d/. -
Run docker-compose.yml (pick one version)
Before first start, create .env and generate its secret (repository root, <ver> = 7.3 or 8.4, §0.6.1): cp compose/web-service/nginx_php-<ver>/.env-example compose/web-service/nginx_php-<ver>/.env bash -c '. script/lib/django_secrets.sh && ensure_env_secrets compose/web-service/nginx_php-<ver>/.env' (REDIS_PASSWORD is empty in .env-example; the helper fills it with a random value.) Then move to the stack folder and execute docker-compose.yml (--build rebuilds the image after an upgrade or a Dockerfile change): PHP 7.3 → cd compose/web-service/nginx_php-7.3 PHP 8.4 → cd compose/web-service/nginx_php-8.4 docker compose up -d --build redis is gated by "profiles: redis" in PHP stacks — start it via: docker compose --profile redis up -d Cannot run both stacks at once: both bind host ports 80/443. To switch versions: "docker compose --profile redis stop" in the running stack first (a plain "stop" leaves the profile-gated redis running), then "up -d --build" in the other. Use stop, not down — see §6 (never "down -v").The PHP
.env-examplehas a different variable set from the Python stacks. PHP stacks use a simplified set:LOG_DRIVER,LOG_OPT_MAXF,LOG_OPT_MAXS,REDIS_PASSWORD,ULIMIT_NOFILE_SOFT/HARD. The Python stack'sPROJECT_DIR / WORKERS / PROJECT_NAME / FLOWER_* / GUNICORN_PORTare not defined for PHP. Instead ofPROJECT_DIR, the fixed webroot is whateverroot /www/php_samplein the nginx sample conf points at (www/php_sample/). To swap in a new php project, createwww/<myphp>/and change onlyroot /www/<myphp>in the nginx conf — no compose variable changes needed.
- PHP 7.3 (legacy) —
-
Gunicorn service
-
Gunicorn service installation [nginx for gunicorn]
In config/web-server/nginx/gunicorn There are 2 shell scripts (nginx_http_conf.sh, nginx_https_conf.sh) Use "chmod +x xxxx.sh" command, you activate shell script and run. then it make conf file nginx's a conf file will be in conf.d folder (<name>_gunicorn_ng_http.conf / <name>_gunicorn_ng_https.conf) Options: "./nginx_http_conf.sh -h" (no manual path escaping needed)Shell script required informations like bellow webroot : ex -> shop_kings domain : ex -> xxxx.com portnumber : ex -> 80 appname : ex -> gunicorn-app (user must be use "container name" referenced in docker-compose.yml file) serviceport : ex -> 8000 (gunicorn application service port) filename : ex -> xxxx (it's the name for nginx's conf file) -
Gunicorn service installation [gunicorn application]
In config/app-server/gunicorn/ - gunicorn.conf.py → Gunicorn settings (workers, bind, user="www-data", group="www-data", ...) The run.sh / make_run.sh pattern is no longer used. The gunicorn-app service in docker-compose.yml runs this directly: command: bash -c "uv sync --inexact --extra gunicorn --extra celery \ && { [ ! -f manage.py ] || python manage.py migrate --noinput; } \ && { [ ! -f prestart.sh ] || bash prestart.sh; } \ && chown -R www-data:www-data /data \ && exec gunicorn -c /gunicorn/gunicorn.conf.py" That single line covers all of: (1) uv syncs the [gunicorn,celery] extras from pyproject.toml into the container's site-packages (no-virtualenv policy, §8) (2) one DB initialisation before the server starts — migrate for Django (manage.py), prestart.sh for non-Django (flask/fastapi). App service only, §0.6.3 (3) chowns only the writable path, the named volume /data (SQLite), to www-data — the host source tree at /www is never chowned (§0.6.2) (4) starts gunicorn with /gunicorn/gunicorn.conf.py (workers as user="www-data") No separate run.sh is needed. To swap in a new project, change PROJECT_DIR in .env and nothing else. -
Run docker-compose.yml
Before first start, create .env and generate its secrets (repository root, §0.6.1): cp compose/web-service/nginx_gunicorn/.env-example compose/web-service/nginx_gunicorn/.env bash -c '. script/lib/django_secrets.sh && ensure_env_secrets compose/web-service/nginx_gunicorn/.env' Then replace FLOWER_ID (CHANGE_ME_FLOWER_USER). CELERY_BROKER_URL is no longer stored in .env — it is composed from REDIS_PASSWORD at compose time (SSOT, see §0.5.3). Then move to the stack folder and run docker-compose.yml (--build rebuilds the images after an upgrade or a Dockerfile / uv.lock change, §0.6.4): cd compose/web-service/nginx_gunicorn docker compose up -d --build For celery / celery-beat / flower: "docker compose --profile celery up -d". (redis-stats has been removed — see §0.5.7)
-
-
UWSGI service
-
UWSGI service installation [nginx for uwsgi]
In config/web-server/nginx/uwsgi There are 2 shell scripts (nginx_http_conf.sh, nginx_https_conf.sh) Use "chmod +x xxxx.sh" command, you activate shell script and run. then it make conf file nginx's a conf file will be in conf.d folder (<name>_uwsgi_ng_http.conf / <name>_uwsgi_ng_https.conf) Options: "./nginx_http_conf.sh -h" (no manual path escaping needed)Shell script required informations like bellow webroot : ex -> shop_kings domain : ex -> xxxx.com portnumber : ex -> 80 appname : ex -> uwsgi-app (user must be use "container name" referenced in docker-compose.yml file) serviceport : ex -> 8000 (uwsgi application service port) filename : ex -> xxxx (it's the name for nginx's conf file) -
UWSGI service installation [uwsgi application]
In config/app-server/uwsgi - uwsgi_conf.sh → uwsgi.ini generator (asks for domain / chdir / module / ...) - uwsgi.ini → the generated file (includes the master's uid=33 gid=33 privilege drop) The run.sh / make_run.sh pattern is no longer used. The uwsgi-app service in docker-compose.yml runs this directly: command: bash -c "uv sync --inexact --extra uwsgi --extra celery \ && { [ ! -f manage.py ] || python manage.py migrate --noinput; } \ && { [ ! -f prestart.sh ] || bash prestart.sh; } \ && chown -R www-data:www-data /data \ && exec uwsgi --ini /application/uwsgi.ini" That single line covers (1) syncing uv's [uwsgi,celery] extras, (2) one DB initialisation before the server starts (migrate or prestart.sh, §0.6.3), (3) chowning only the /data volume to www-data — the source tree at /www is untouched, and (4) starting the uwsgi master (uid/gid = www-data). No separate run.sh is needed. To swap in a new project, change PROJECT_DIR in .env and nothing else. -
Run docker-compose.yml
Before first start, create .env and generate its secrets (repository root, §0.6.1): cp compose/web-service/nginx_uwsgi/.env-example compose/web-service/nginx_uwsgi/.env bash -c '. script/lib/django_secrets.sh && ensure_env_secrets compose/web-service/nginx_uwsgi/.env' Then replace FLOWER_ID. CELERY_BROKER_URL is no longer in .env (see §0.5.3). Then move to the stack folder and run docker-compose.yml (--build rebuilds the images after an upgrade or a Dockerfile / uv.lock change, §0.6.4): cd compose/web-service/nginx_uwsgi docker compose up -d --build For celery / celery-beat / flower: "docker compose --profile celery up -d". (redis-stats has been removed — see §0.5.7)
-
-
Uvicorn (ASGI) service
The §0.5.9 sync added
config/web-server/nginx/uvicorn/(the whole folder) andconfig/app-server/uvicorn/gunicorn_uvicorn.conf.py, so this stack can now generate and run its nginx side properly. Earlier versions had only thecompose/.../nginx_uvicorn/stack with no nginx conf folder, leaving no way to create a domain conf.-
Uvicorn service installation [nginx for uvicorn]
In config/web-server/nginx/uvicorn There are 2 shell scripts (nginx_http_conf.sh, nginx_https_conf.sh) - nginx_http_conf.sh → sample_nginx_http.conf → conf.d/<name>_uvicorn_ng_http.conf - nginx_https_conf.sh → sample_nginx_https.conf → conf.d/<name>_uvicorn_ng_https.conf Use "chmod +x xxxx.sh" command, you activate shell script and run.Shell script required informations like bellow webroot : ex -> fastapi_sample (or django_sample for ASGI Django) domain : ex -> xxxx.com portnumber : ex -> 80 appname : ex -> uvicorn-app → must match container_name in compose/web-service/nginx_uvicorn/docker-compose.yml serviceport : ex -> 8000 filename : ex -> xxxx (it's the name for nginx's conf file) -
Uvicorn service installation [uvicorn application]
In config/app-server/uvicorn there are TWO config files: - uvicorn.conf.py → for starting uvicorn directly - gunicorn_uvicorn.conf.py → the gunicorn + UvicornWorker pattern (prefork ASGI, many workers) Both load via UV_PROJECT_ENVIRONMENT=/usr/local (no-virtualenv policy, §8). -
Run docker-compose.yml
Before first start, create .env and generate its secrets (repository root, §0.6.1): cp compose/web-service/nginx_uvicorn/.env-example compose/web-service/nginx_uvicorn/.env bash -c '. script/lib/django_secrets.sh && ensure_env_secrets compose/web-service/nginx_uvicorn/.env' Then replace FLOWER_ID. CELERY_BROKER_URL is composed from REDIS_PASSWORD (§0.5.3). Then move to the stack folder and run docker-compose.yml (--build rebuilds the images after an upgrade or a Dockerfile / uv.lock change, §0.6.4): cd compose/web-service/nginx_uvicorn docker compose up -d --build For celery / celery-beat / flower: "docker compose --profile celery up -d".
-
-
Daphne (ASGI WebSocket) service — a devspoon-specific stack
Stack: compose/web-service/nginx_daphne/ Logrotate dropins: script/logrotate/daphne/{daphne,celery/daphne-celery,celerybeat/daphne-celerybeat} Image: shares devspoon-py-app:latest (same base as gunicorn / uvicorn, §0.5.4) Use case: WebSocket-only requirements such as Django Channels. The nginx side reuses the gunicorn stack's config/web-server/nginx/gunicorn/ folder, mounted as is.-
Run docker-compose.yml
Before first start, create .env and generate its secrets (repository root, §0.6.1): cp compose/web-service/nginx_daphne/.env-example compose/web-service/nginx_daphne/.env bash -c '. script/lib/django_secrets.sh && ensure_env_secrets compose/web-service/nginx_daphne/.env' Then replace FLOWER_ID. CELERY_BROKER_URL is composed from REDIS_PASSWORD (§0.5.3). Then move to the stack folder and run docker-compose.yml (--build rebuilds the images after an upgrade or a Dockerfile / uv.lock change, §0.6.4): cd compose/web-service/nginx_daphne docker compose up -d --build For celery / celery-beat / flower: "docker compose --profile celery up -d".
Give the WebSocket path its own location in the domain conf and forward the Upgrade header.
nginx.confdefinesmap $http_upgrade $connection_upgrade. Do not includeproxy_paramsin this location — it setsConnection "", which would send the same header twice:location /ws/ { proxy_pass http://daphne-app:8000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; }
-
-
The sample apps under www/ are references for verifying each stack immediately. Put your production code in the same place, in its own folder.
| Folder | Backend / stack | Notes |
|---|---|---|
www/django_sample/ |
Usable from gunicorn, uwsgi, daphne and uvicorn. Django 6.0 (django>=6.0,<6.1), uv-managed (pyproject.toml, uv.lock) |
.python-version = 3.14. On the host, uv sync --extra celery creates .venv automatically (settings' django_celery_beat belongs to the celery extra); in containers it installs straight into the system site-packages (§8) |
www/fastapi_sample/ |
uvicorn only. Latest FastAPI, uv-managed | Added in §0.5.9 — verifies the uvicorn stack |
www/flask_sample/ |
gunicorn or uwsgi only (WSGI). Flask, uv-managed | Added in §0.5.9 — verifies the WSGI stacks with a non-Django app |
www/php_sample/ |
For php-fpm (7.3 or 8.4). A single index.php |
Container path /www/php_sample (mounted as ../../../www:/www) |
www/certbot/ |
The standard ACME webroot location inside the container | Only .gitkeep is tracked to keep the empty directory. When issuing a certificate, the nginx conf aliases /.well-known/acme-challenge/ to this path |
The usage flow is unchanged:
1) New app: create www/myapp/
2) Generate the domain conf: config/web-server/nginx/<stack>/nginx_http_conf.sh -w myapp -d ...
3) Set PROJECT_DIR=myapp in compose/web-service/nginx_<stack>/.env
(docker-compose.yml only substitutes ${PROJECT_DIR}; the value itself lives in .env)
4) cd compose/web-service/nginx_<stack> && docker compose up -d --build
Each stack's config/web-server/nginx/<stack>/conf.d/ holds exactly two kinds of permanent .conf file (no .example pattern — single-extension policy):
| File | Role | Behaviour |
|---|---|---|
default.conf |
Sole owner of catch-all | listen 80 default_server + listen 443 ssl default_server + server_name _ + return 444 / ssl_reject_handshake on. Blocks unknown Host/SNI. The only place default_server is defined |
<sample>_<stack>_ng_http.conf |
Permanent sample for instant verification | listen 80; (no default_server) limited to server_name localhost www.localhost;. Loaded automatically when the nginx container starts, so Host: localhost returns HTTP 200 |
| Stack | Sample conf file |
|---|---|
| gunicorn | django_sample_gunicorn_ng_http.conf |
| uvicorn | django_sample_uvicorn_ng_http.conf |
| uwsgi | django_sample_uwsgi_ng_http.conf |
| php (shared by 7.3/8.4) | sample_php_ng_http.conf |
Instant verification (right after compose up):
curl -H "Host: localhost" http://localhost/ # → 200No activation step (such as cp .example .conf) is needed. The sample's listen 80; and default.conf's listen 80 default_server; share the same port without conflict, because the default_server keyword appears only in default.conf.
Adding a per-domain production conf: nginx_http_conf.sh -w <webroot> -d <domain> -p 80 -a <appname> -s <serviceport> -n <name> creates conf.d/<name>_<stack>_ng_http.conf. It has a different server_name from the sample, so the two coexist. To disable the sample in production, move it out of conf.d/ or delete it (it is a permanent .conf, so it is tracked in git).
-
User can access using defined folders in docker-compose.yml
Example -> nginx container has volumes like below that /www /script/ /etc/nginx/conf.d/ /etc/nginx/nginx.conf /etc/nginx/uwsgi_params /ssl/ /log- If user run containers at same server, can update code and move files directly from local server folder to container folder.
-
If user use firewall, have to add required port number (refer each docker-compose.yml files)
Example ufw allow 80/tcp ufw allow 3306/tcp
-
This step requires running http nginx server
-
Run nginx_http_conf.sh located in config/web-server/nginx/. Create a conf file for each domain under config/web-server/nginx//conf.d/. Generated filenames always end with "_http" (e.g. _gunicorn_ng_http.conf).
-
Please edit compose/web-service//docker-compose directly and run it according to the service you want to use.
-
This will run the default nginx using http.
-
The "docker exec -it bash" command allows users to access docker internals.
-
The script/letsencrypt.sh shell script file is linked per volume. This allows users to access script files directly from the nginx container.
-
Run /script/letsencrypt.sh and enter the domain(s) and email. The ACME webroot is fixed to /www/certbot (every generated conf serves /.well-known/acme-challenge/ from it), so there is no webroot input.
-
If you entered all keys correctly, use the exit command to exit the container.
-
Now we need to create a conf file for https and delete the existing file.
-
Run nginx_https_conf.sh located in config/web-server/nginx/. Create a conf file for each domain under config/web-server/nginx//conf.d/.
-
Users must remove the http conf file from config/web-server/nginx//conf.d/.
-
Apply the new conf in the compose folder:
docker compose exec webserver nginx -t && docker compose exec webserver nginx -s reload(or restart only nginx:docker compose restart webserver). Do not restart the whole stack for this —docker compose restartrestarts every service at the same time, so nginx can come up while the app container is still stopping and exit once with[emerg] host not found in upstream(it restarts itself, see §5). To restart everything, usedocker compose stopthendocker compose start. Do not use the "docker compose down" command. Related configuration files may be deleted. -
The certbot renewal cron is built into the nginx image (
docker/nginx/Dockerfileregisters it in crontab at build time, and the cron daemon starts with the container). You do not need a hostcrontabentry or an external script. On renewal,--deploy-hook "nginx -t && nginx -s reload"makes nginx reload gracefully only when the certificate actually changed. For the full story, see "The single source of truth for the renewal cron" in §3. -
Check the registered cron inside the container:
docker compose exec webserver crontab -l(certbot and ngxblocker renewals). The app container'sdocker/<stack>/entrypoint-with-cron.shhandles the logrotate cron only.
-
This section gathers the background knowledge, recommended settings and behaviours to watch out for when deploying and operating this project from an infrastructure / operations point of view. Please read it before deploying to production.
| Item | Value | Notes |
|---|---|---|
| Server spec | 8 cores / 8 GB RAM | Every tuning number in this README is derived from this baseline. Recalculate the worker/pool figures for a different spec |
| Deployment | manual stop/start (no zero-downtime) | docker compose stop → update the code → docker compose start. Zero-downtime, blue-green and rolling deployments are not supported |
| Container base | ubuntu:24.04 (LTS), nginx:1.27-bookworm |
No latest tags — reproducible builds |
| Python | 3.14.0 (compiled from source, multi-stage builder + SHA256 verification) |
Longer build, 5–10 % faster at runtime. SHA256 ARG default = 2299dae5...e9f3e9 (§0.5.4) |
| PHP 7.3 (legacy) | romeoz/docker-phpfpm:7.3 |
docker/php-fpm/Dockerfile-7.3. Multi-version paths (/etc/php/7.3/fpm/...). Upstream is unmaintained — prefer 8.4 for new deployments |
| PHP 8.4 (current) | php:8.4-fpm-bookworm (official) |
docker/php-fpm/Dockerfile-8.4. Single-path layout (/usr/local/etc/php{,-fpm.d}/...). php.ini is patched for 8.x compatibility (config/app-server/php-8.4/php_ini/php.ini) |
| Redis | redis:7.4-alpine + protected-mode yes + requirepass |
Alpine base keeps the image under 100 MB. Authentication is mandatory (§0.5.2) |
| Flower | mher/flower:2.0.1 |
The master tag is not reproducible and is forbidden. FLOWER_BASIC_AUTH is required |
| Image tags built here | devspoon-py-app:latest, devspoon-uwsgi-app:latest, devspoon-nginx:latest, devspoon-php-app:{7.3,8.4} |
Declared with compose image: so stacks and services reuse them (§0.5.4). The devspoon prefix is IMAGE_NAMESPACE (default devspoon) — the verifiers and verify-ngxblocker.sh use IMAGE_NAMESPACE=devspoon-it, and run-ci's build step (s2_build.sh) uses devspoon-test/* so production tags are never overwritten (§0.6.4) |
- This project primarily targets a single server (8c/8g). Without a load balancer or orchestrator (K8s), this is the simplest and safest deployment model.
- Giving up zero-downtime (graceful HUP reload) buys lower memory use:
- gunicorn uses
preload_app=True(gunicorn.conf.py,uvicorn.conf.py— loaded once in the master then forked, shared copy-on-write) - uwsgi uses
lazy-apps = true(uwsgi.ini— each worker forks then loads the app; it gives up CoW savings to avoid shared state from before the fork)
- gunicorn uses
- When zero-downtime does become a requirement, handle it in a dedicated PR / migration.
This section collects the policy changes applied together most recently. Some differ from the defaults described in earlier versions of this README, so read this section before the operations guide from §1 onwards.
- The
compose/web-service/<stack>/.envof each of the six stacks (daphne / gunicorn / uvicorn / uwsgi / php-7.3 / php-8.4) is no longer tracked in git. Only.env-examplein the same folder is tracked; on a new environment,cp .env-example .envand generate the secrets withensure_env_secrets(§0.6.1). - The
.gitignorepattern is**/.env;.env-exampledoes not match it and stays tracked. The five previously tracked.envfiles were untracked withgit rm --cached(the working tree copies were kept). - compose's
${VAR:?error}validation: if a credential key (DJANGO_SECRET_KEYfor Python stacks,REDIS_PASSWORD,FLOWER_ID,FLOWER_PWD) is unset or empty,docker compose upfails fast — which rules out starting with an empty password. - The logging keys (
LOG_DRIVER,LOG_OPT_MAXF,LOG_OPT_MAXS) fall back with${VAR:-default}, so a missing.enventry has no effect.
- All six
redis.conffiles changeprotected-mode notoyes. Even inside the same docker network, no key is reachable without authentication. redis.confdoes not support environment variable interpolation, sorequirepassis injected from compose withcommand: redis-server ... --requirepass ${REDIS_PASSWORD}.- A healthcheck was added to the redis container:
healthcheck: test: ["CMD-SHELL", "redis-cli --no-auth-warning -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"] interval: 10s timeout: 3s retries: 5 start_period: 10s
depends_on: redison the app, celery, celery-beat and flower was strengthened tocondition: service_healthy, holding dependent services back until redis answers authenticated pings — which removes the race condition.
- It used to be stored separately in
.envasCELERY_BROKER_URL=redis://:PASS@redis:6379/3, so it had to be kept in sync withREDIS_PASSWORD— a drift risk. - The
CELERY_BROKER_URLkey has now been removed from.env. The celery, celery-beat and flowerenvironment:blocks in compose compose it directly asredis://:${REDIS_PASSWORD:?...}@redis:6379/3, so updating the single REDIS_PASSWORD value keeps all four services in sync. - A side benefit: previously the celery and celery-beat containers received no
CELERY_BROKER_URLenvironment variable, so a Django settings module readingos.environ['CELERY_BROKER_URL']fell back toamqp://localhost— a latent bug this change removes.
docker/gunicorn/Dockerfileanddocker/uwsgi/Dockerfilewere restructured as multi-stage builds:- builder stage:
ubuntu:24.04+build-essential+*-devlibraries + Python 3.14 compiled from source + pip packages pre-installed (including natively built ones such as mysqlclient and psycopg2). - runtime stage:
ubuntu:24.04+ runtime libraries only + percona-xtrabackup-84 + pgbackrest + cron + logrotate. Droppingbuild-essentialandpkg-configsaves roughly 500 MB.
- builder stage:
- SHA256 verification of the Python tarball was added:
ARG PYTHON_SHA256=2299dae542d395ce3883aca00d3c910307cd68e0b2f7336098c8e7b7eee9f3e9(official Python 3.14.0, confirmed 2026-05-18).- If
sha256sum -cexits non-zero during the build, the RUN aborts — supply-chain tampering is detected. - When bumping the version, update the ARG after checking the python.org Files table or verifying the
.sigstorebundle.
- Explicit
image:tags in compose let stacks and services reuse images:devspoon-py-app:latest— shared by the gunicorn, daphne and uvicorn stacks, and by celery / celery-beat inside each.devspoon-uwsgi-app:latest— the uwsgi stack only.devspoon-nginx:latest,devspoon-php-app:7.3/devspoon-php-app:8.4.- Effect:
docker compose buildused to rebuild the same context three or four times; now it builds once.
username = rootwas removed anduid = www-data/gid = www-dataenabled. The master drops from root to www-data, mirroring the PHP-FPM pattern (least privilege).chmod-socket = 666is commented out — it is meaningless over TCP 8000. An inline comment explains to use 0660 if you switch to a unix socket later.py-autoreloadinsample_uwsgi.inichanged from1to0, so a default unsuitable for production does not come back when the sample is copied.
.envdefinesULIMIT_NOFILE_SOFT=65535andULIMIT_NOFILE_HARD=65535.- The ulimits block is present in the
webserverservice of all six compose files but commented out, because the host docker daemon's default LimitNOFILE (typically 1048576) already covers 65535. Uncomment it only where the host OS caps nofile below 65535 (RHEL, podman, some K8s nodes).
insready/redis-stat:latest(unmaintained since 2017, no security patches) was deleted from all six compose files, along with its host63790/tcpexposure.- If you need a replacement, consider RedisInsight or
oliver006/redis_exporter(for Prometheus).
dpkg-reconfigure tzdatawas removed fromDockerfile-7.3andDockerfile-8.4. They now use the same pattern as the gunicorn, uwsgi and nginx Dockerfiles:ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone.- Six duplicated install layers in
Dockerfile-7.3were compressed into a single RUN.
The secrets in .env-example are empty (DJANGO_SECRET_KEY, REDIS_PASSWORD, FLOWER_PWD for Python stacks; REDIS_PASSWORD for php). Compose requires them with ${VAR:?}, so startup is refused while they are blank. Run this once per stack, from the repository root:
cp compose/web-service/nginx_gunicorn/.env-example compose/web-service/nginx_gunicorn/.env
bash -c '. script/lib/django_secrets.sh && ensure_env_secrets compose/web-service/nginx_gunicorn/.env'- Only secret keys that are empty or still hold the old
CHANGE_ME_*placeholder are filled withopenssl rand -hexvalues (DJANGO_SECRET_KEY100 hex, everything else 64 hex). Keys that already have a value are left alone. - The helper writes to a temporary file in the same folder and swaps it in; if it generated anything, it tightens the permissions to 600 (stricter permissions are kept). If openssl is missing or fails it ends with
FAILand.envis untouched. FLOWER_IDis not a secret and is not filled — replaceCHANGE_ME_FLOWER_USERyourself.www/django_sample/secrets.jsonis only needed when you runmanage.pydirectly on the host:bash -c '. script/lib/django_secrets.sh && ensure_django_secrets'(created only when missing, mode 600). Install the dependencies withcd www/django_sample && uv sync --extra celery—django_celery_beatinINSTALLED_APPSexists only in theceleryextra, so without--extra celeryyou get aModuleNotFoundError. Containers use theDJANGO_SECRET_KEYenvironment variable.
Upgrade note — keeping an
.envfrom an older version: an old.envmay have noDJANGO_SECRET_KEYline at all, or may still holdCHANGE_ME_*values. Running the helper once on that same.envappends the missing keys — those required with:?bydocker-compose*.ymlin the same folder and by any fragment they pull in withinclude:, whose names contain SECRET, PASSWORD or PWD — replacesCHANGE_ME_*, preserves existing values and sets the file mode to 600. A quoted empty value such asKEY=""is not filled; change it toKEY=first.
For the Python stacks (gunicorn, uvicorn, uwsgi, daphne), SQLite lives at /data/<PROJECT_DIR>.sqlite3 in the compose named volume app-data (the SQLITE_PATH environment variable), not at www/<PROJECT_DIR>/db.sqlite3 on the host. The container chowns only that volume and the log directories to www-data; the host source tree (/www) is never chowned. Running manage.py on the host without SQLITE_PATH still uses www/<PROJECT_DIR>/db.sqlite3.
Migrating an existing database (to keep using the host db.sqlite3 — gunicorn stack shown; start the celery profile after the migration):
cd compose/web-service/nginx_gunicorn
docker compose up -d # creates the app-data volume (migrated as an empty DB)
docker compose cp ../../../www/django_sample/db.sqlite3 gunicorn-app:/data/django_sample.sqlite3
docker compose exec gunicorn-app chown www-data:www-data /data/django_sample.sqlite3
docker compose restart gunicorn-app # the startup command runs again and applies pending migrations to the copied DB (app only — a full restart hits the race in §5)
⚠️ docker compose down -vdeletes theapp-datavolume, and with it the SQLite database. Usedocker compose stopwhen you only want to bring the containers down (downis discouraged by the §6 deployment policy,-vespecially). Backup:docker compose cp gunicorn-app:/data/django_sample.sqlite3 ./backup.sqlite3.
- Only the app service of each stack initialises the database once before the server starts:
python manage.py migrate --noinputwhenmanage.pyexists, otherwise (flask/fastapi) the project'sprestart.sh. Put table creation for a new non-Django project inwww/<PROJECT_DIR>/prestart.sh. celeryandcelery-beatare bound withdepends_on: <app>: condition: service_healthy, so they start after the app is healthy — no two containers race to migrate.flowerstarts aftercelery-beat.
-
Image names follow
${IMAGE_NAMESPACE:-devspoon}-nginx:latest. With the default you get the familiardevspoon-*tags; the verifiers andverify-ngxblocker.shuseIMAGE_NAMESPACE=devspoon-it, and run-ci's build step (s2_build.sh) usesdevspoon-test/*, so production tags are never overwritten. -
The pre-installed packages in the app images (
py-app,uwsgi-app) are derived fromwww/django_sample/uv.lock. Compose passes it automatically viabuild.additional_contexts: lock: ../../../www/django_sample, which needs Docker Compose ≥ 2.17 (docker compose version). -
If your Compose is older than 2.17, or you call
docker builddirectly, pass the extra build context explicitly (from the repository root, with BuildKit):docker build --build-context lock=www/django_sample -t devspoon-py-app:latest docker/gunicorn/ docker build --build-context lock=www/django_sample -t devspoon-uwsgi-app:latest docker/uwsgi/
Leaving it out makes the build fail with
"/pyproject.toml": not found.
- Rate limiting — bots only: the shared
nginx.confhttp block defineslimit_conn_zone $bot_iplimit zone=addr:50m;andlimit_req_zone $bot_iplimit zone=flood:50m rate=90r/s;.$bot_iplimitonly carries an IP for requestsglobalblacklist.confjudged to be a bot, so the limits inbots.d/ddos.confapply to bot traffic and leave normal users alone. The upstreambotblocker-nginx-settings.confis not included (its hash directives were moved into nginx.conf). proxy.d: nginx.conf readsinclude /etc/nginx/proxy.d/*/*.conf;before conf.d. The compose files here do not mountproxy.d, so it is a no-op; sibling repositories use it when they mount extra reverse-proxy services at/etc/nginx/proxy.d/<svc>/.- WebSocket: nginx.conf defines
map $http_upgrade $connection_upgrade(see the Daphne example). - real_ip: all four nginx.conf files carry commented example blocks for CloudFlare, AWS ALB and an in-house LB; they are disabled by default (OPS-GUIDE-006 §2).
- HTTPS template (
sample_nginx_https.conf): HSTS ismax-age=63072000; includeSubDomains—preloadis deliberately absent (add it only after you decide to register at hstspreload.org, OPS-GUIDE-002 §2). OCSP stapling is off by default (Let's Encrypt certificates carry no OCSP responder URL). Session cache isshared:SSL:10m.
DJANGO_DEBUG(default0) andDJANGO_ALLOWED_HOSTS(gunicorn defaultlocalhost,www.localhost,127.0.0.1) are controlled through.envand passed to the app, celery and beat. Add your domain toDJANGO_ALLOWED_HOSTSwhen you connect one, and useDJANGO_DEBUG=1only for local development.- Flower binds to
127.0.0.1:5555only. Reach it over an SSH tunnel:ssh -L 5555:127.0.0.1:5555 <host>, then openhttp://127.0.0.1:5555locally.
| Variable | Default | Purpose |
|---|---|---|
IMAGE_NAMESPACE |
devspoon (the verifiers and verify-ngxblocker.sh use devspoon-it; run-ci's s2_build.sh uses devspoon-test/*) |
Image name prefix |
S5_DOMAIN |
s5-https.test |
The test HTTPS domain used by s5_https.sh |
S5_WAIT |
20 |
Maximum seconds s5_https.sh waits for an HTTPS response after a reload |
STABLE_WINDOW |
15 |
The stabilisation window (seconds) over which a verifier observes containers staying running with an unchanged RestartCount |
bash script/ci/run-ci.sh runs eleven steps in order: preflight → dependency audit (uv audit over every www/*/uv.lock) → prereq and log directories → nginx conf generators → compose validation → image builds → static regression (s6) → healthcheck → stack matrix → sample projects → script logs.
Memory available on an 8-core / 8 GB box: 8 GB - (OS + nginx + redis + headroom ≈ 2 GB) ≈ 6 GB.
| Service | Key figures | Rationale |
|---|---|---|
| gunicorn (Django sync) | workers=4, threads=2 (gunicorn.conf.py) |
At ~250 MB per worker, 4 × ≈ 1 GB. threads=2 absorbs DB I/O waits, giving 8 concurrent slots. timeout=60, max_requests=1000, preload_app=True |
| uvicorn (FastAPI ASGI) | workers=8 (UvicornWorker, uvicorn.conf.py — the one compose uses) |
Async workers handle many requests on a single event loop; one worker per core. At ~600 MB per worker, 8 × ≈ 4.8 GB. The alternative gunicorn_uvicorn.conf.py uses workers=4 |
| uwsgi (Django) | processes=4 (no threads option, enable-threads=true) |
Sync workers. harakiri=60, lazy-apps=true. reload-on-rss is unset — add it if you want automatic restarts on memory growth |
| daphne (ASGI WS) | single process | daphne has no multi-process mode. A single instance handles thousands of concurrent websockets; beyond that, use an nginx upstream with several containers |
| celery worker | --concurrency=8 --max-tasks-per-child=2000 |
Prefork pool matched to the core count. Workers restart every 2000 tasks as a defence against memory leaks |
| php-fpm (shared by 7.3/8.4) | pm = dynamic, pm.max_children=50, start=5, min_spare=5, max_spare=35 |
At ~80 MB per worker, 50 × ≈ 4 GB. request_terminate_timeout=60s guards against hangs. Both versions use the same pool policy — the files are config/app-server/php-{7.3,8.4}/pool.d/www.conf, and the only difference is the 8.x compatibility patch in php.ini |
Caution — the side effects of
preload_app=True:
- If you open a DB connection at module import time, the forked workers share one socket and can collide.
- Fix: create DB connections lazily on the first request, or recreate them explicitly in a
post_forkhook.- The Django ORM handles this for you; SQLAlchemy with raw psycopg2 and similar setups do not.
Every log goes into a single host-side ./log/ tree, mounted inside the containers at /log/.
log/
├── nginx/ # nginx access/error + certbot renewal logs
├── gunicorn/ # gunicorn_access.log, gunicorn_error.log
│ ├── celery/ # worker-*.log
│ └── celerybeat/ # celerybeat.log
├── uvicorn/ # uvicorn_access.log, uvicorn_error.log
│ ├── celery/
│ └── celerybeat/
├── uwsgi/ # <project>-uwsgi.log, daemonize, _access.log
│ ├── celery/
│ └── celerybeat/
├── daphne/ # stdout.log, error.log, access.log
│ ├── celery/
│ └── celerybeat/
├── php-fpm/ # access.log, www-error.log, slow.log
└── supervisor/ # (reserved slot)
- Logs survive a dead container (host volume mount).
- Every folder is tracked with
.gitkeep, directly or through a subfolder. - When you add a new service, always add
log/<service>/and its.gitkeep.
- The host's
script/logrotate/<service>/<service>is mounted into the container at/etc/logrotate.d/<service>. - Policy:
copytruncate— rotation without restarting the service (the trade-off is that a tiny amount of log can be lost at the moment of rotation). - Retention: 30 days by default (php-fpm access 7 days, slow log 90 days — diagnosis first).
- Check that it applies:
docker exec -it <container> logrotate -d /etc/logrotate.d/<service>
docker/nginx/Dockerfileis the only source of truth.script/letsencrypt.shis for the initial issuance only — it registers no cron line (deliberately removed).- The registered cron:
0 5 * * 1 certbot renew --quiet --deploy-hook "nginx -t && nginx -s reload" >> /log/nginx/crontab_YYYYMMDD.log 2>&1
service nginx restartandsystemctl reload nginxare forbidden — in a container where PID 1 is nginx they either do nothing or kill the whole container.nginx -s reloadsendsSIGHUPto the master, which spawns new workers and shuts the old ones down gracefully.- Always
nginx -t &&first, so a broken config never reaches production on reload.
docker exec -it nginx-<service>-webserver bash -c "crontab -l"
docker exec -it nginx-<service>-webserver bash -c "ps aux | grep cron"
# watch the next run
tail -f log/nginx/crontab_*.log- Start the container with an HTTP-only nginx conf
docker exec -it <nginx-container> bash- Run
/script/letsencrypt.sh(enter webroot / domain / e-mail) exitonce it is issued- Swap in the HTTPS conf →
docker compose exec webserver nginx -t && docker compose exec webserver nginx -s reload
Background: dhparam (ssl_dhparam) is neither a secret nor domain-specific, so one shared copy for the whole stack is enough. Older versions mounted the host's ssl/certs/ over /etc/ssl/certs, which shadowed the system CA bundle (/etc/ssl/certs/ca-certificates.crt) and broke certbot issuance. This version removes that anti-pattern and replaces it with baking it once at build time plus a host backup/restore hook.
| Location | Role | Who creates it |
|---|---|---|
docker/nginx/Dockerfile section 8 |
openssl dhparam -out /etc/nginx/dhparam.pem 2048 — bakes dhparam into the image |
Build step |
docker/nginx/Dockerfile section 9 / /docker-entrypoint.d/20-dhparam.sh |
Restores from the host backup if there is one, otherwise creates it | Hook just before nginx starts (the official image's entrypoint.d) |
compose/web-service/<stack>/ssl/dhparam/ (host) |
Restore source / backup target, mounted at /etc/nginx/dhparam-backup/ |
The operator, or the automatic backup hook |
/etc/nginx/dhparam.pem (container) |
The file nginx actually reads — the path ssl_dhparam points at in sample_nginx_https.conf |
The hook, restoring or using the baked copy |
How it plays out:
- First start (host
ssl/dhparam/empty) → the hook copies the image's dhparam.pem into the host backup directory. The same key is now persisted on the host. - Restart after
docker compose down→ a host backup exists, so the hook restores it over the image's copy. nginx uses exactly the dhparam key it used on the first start. - Image rebuild (for example bumping the nginx base version, which bakes a fresh dhparam) → the hook prefers the host backup, keeping the production key identical. To adopt the new dhparam deliberately, delete the host's
ssl/dhparam/dhparam.pemand restart.
When nginx starts it resolves the app container names written in proxy_pass / uwsgi_pass / fastcgi_pass. If the webserver comes up before the app — after a host reboot, or on docker compose start — the name does not exist and nginx exits with [emerg] host not found in upstream (restart: always brings it back, but it dies once).
| Location | Role |
|---|---|
docker/nginx/Dockerfile section 10 / /docker-entrypoint.d/30-wait-upstreams.sh |
Waits until the upstream names in conf.d (and proxy.d in the startup-series repositories) resolve, then starts nginx |
NGINX_UPSTREAM_WAIT (webserver environment) |
Maximum wait in seconds. Default 30; 0 disables the hook |
- Excluded from the wait: unix sockets,
$variables, IP literals,localhost, and names defined by anupstreamblock within the conf. - If a name still does not resolve when the wait expires, the hook logs a warning and starts anyway (a genuine misconfiguration still surfaces as nginx's
[emerg]). - A whole-project
docker compose restartrestarts services simultaneously, so the app can go down after the hook has checked — apply config changes withnginx -s reloadand restart everything withstop→start(§5). - Regression:
script/test_run/s6_regression.sh6.38.
Verification:
# is dhparam baked into the freshly built image?
docker run --rm devspoon-nginx:latest cat /etc/nginx/dhparam.pem | head -1
# → "-----BEGIN DH PARAMETERS-----"
# check the host backup after starting the container
ls -la compose/web-service/nginx_gunicorn/ssl/dhparam/
# → dhparam.pem should exist
# confirm the same key is in use (compare digests)
docker exec -it nginx-gunicorn-webserver sha256sum /etc/nginx/dhparam.pem
sha256sum compose/web-service/nginx_gunicorn/ssl/dhparam/dhparam.pem
# → the two values should matchFor a fuller verification script, use script/test_run/ssl_diag.sh (see §10.3).
Note for WSL: the host dhparam backup (
ssl/dhparam/dhparam.pem) can be created owned by container root, leaving the host user unable to change it. Modify or delete it from inside the container, or withsudoon the host (see §11.2 for the procedure).
The old static bad_bot.conf (500+ patterns maintained by hand) is gone, replaced by nginx-ultimate-bad-bot-blocker, which refreshes from upstream every six hours.
- Downloads
install-ngxblocker,setup-ngxblockerandupdate-ngxblocker - Runs
install-ngxblocker -x, baking the initial data into the container:/etc/nginx/conf.d/globalblacklist.conf(the map definitions for every bot / scanner / scraper — provides$bad_bot,$bad_referer,$validate_refererand friends)/etc/nginx/bots.d/blockbots.conf(server-level blocking logic)/etc/nginx/bots.d/ddos.conf(DDoS pattern blocking)/etc/nginx/bots.d/{blacklist,whitelist}-*.conf(empty files for operator customisation)
- Cron inside the container runs
update-ngxblockerevery six hours, refreshing only globalblacklist.conf, thennginx -t && nginx -s reload(graceful reload) - The registered cron line:
0 */6 * * * /usr/local/sbin/update-ngxblocker >> /log/nginx/ngxblocker_YYYYMM.log 2>&1 && nginx -t && nginx -s reload
- Same source of truth as the certbot renewal cron (the Dockerfile) — no extra sudo work in production
The old if ($bad_bot) { return 403; } in each domain server block is replaced by this two-line include.
Of ngxblocker's nine bots.d/ files, only these two belong in the server context:
include /etc/nginx/bots.d/blockbots.conf; # server-level `if ($bad_bot)` check + return 444
include /etc/nginx/bots.d/ddos.conf; # server-level limit_conn / limit_req (bots only)
⚠️ Do not includebots.d/{whitelist,blacklist}-*.conf,bots.d/bad-referrer-words.conforbots.d/custom-bad-referrers.confdirectly in a server block. Those files holdmap/geodata entries (1.2.3.4 1;,~*pattern 1;) and are only valid inside a map/geo block in the http context. Leave them in a server block and the moment an operator adds a single entry,nginx -tfails → the reload is refused → production goes down. globalblacklist.conf includes those seven files automatically in the http context, so an operator only has to add entries to the files themselves — no server block changes.
The customisation layer upstream never touches — update-ngxblocker does not overwrite these.
Just add entries to the file; globalblacklist.conf picks them up in the http context.
Do not modify the server block:
| File | Purpose | Example format |
|---|---|---|
/etc/nginx/bots.d/whitelist-ips.conf |
IPs/CIDRs never to block | 203.0.113.0/24 0; |
/etc/nginx/bots.d/whitelist-domains.conf |
Referer domains never to block | ~*example\.com 0; |
/etc/nginx/bots.d/blacklist-ips.conf |
Extra IPs/CIDRs to block | 198.51.100.5 1; |
/etc/nginx/bots.d/blacklist-user-agents.conf |
Extra User-Agents to block | ~*MyEvilBot 1; |
/etc/nginx/bots.d/custom-bad-referrers.conf |
Extra referrer keywords to block | ~*spam\-keyword 1; |
⚠️ bots.d/blacklist-domains.confis not included by globalblacklist.conf, so entries added there have no effect. The Dockerfile only touches it into existence as an empty file. Block extra referer domains throughcustom-bad-referrers.conf, or throughblacklist-user-agents.confif you can match on the UA.
After editing: docker exec -it nginx-<svc>-webserver bash -c "nginx -t && nginx -s reload".
# 1) confirm a known bot User-Agent is blocked
curl -A "MJ12bot" -o /dev/null -s -w "%{http_code}\n" http://localhost/
# → 444 (or 403) is correct
# 2) a normal browser UA passes
curl -A "Mozilla/5.0" -o /dev/null -s -w "%{http_code}\n" http://localhost/
# → 200/3xx/4xx (not 502/503)
# 3) check the update log
docker exec -it nginx-<svc>-webserver tail -20 /log/nginx/ngxblocker_$(date +%Y%m).log# 1) temporarily disable globalblacklist.conf inside the container
# (ngxblocker is installed with -c /etc/nginx, so it sits directly under /etc/nginx/, not conf.d.
# Simply moving the file makes the include line in nginx.conf a file-not-found and nginx -t fails —
# commenting the include line out is safer. nginx.conf is bind-mounted from the host, so a sed
# inside the container updates the host file too and the change survives the next start.)
docker exec -it nginx-<svc>-webserver sed -i \
's|^\(\s*\)include /etc/nginx/globalblacklist.conf|\1# include /etc/nginx/globalblacklist.conf|' \
/etc/nginx/nginx.conf
docker exec -it nginx-<svc>-webserver bash -c "nginx -t && nginx -s reload"
# to revert: restore the include line in nginx.conf from git → reload
# 2) or restore the static bad_bot.conf from before the ngxblocker migration, from git
# git log --diff-filter=D -- config/web-server/nginx/gunicorn/conf.d/bad_bot.conf # find the deleting commit
# git show <DELETE_COMMIT>^:config/web-server/nginx/gunicorn/conf.d/bad_bot.conf > config/web-server/nginx/gunicorn/conf.d/bad_bot.conf
# then git revert the sample_nginx*.conf changes, remount into the container's conf.d and reloadFor backlog=2048 (gunicorn / uwsgi / php-fpm) to have any real effect, raise the kernel parameters too.
# /etc/sysctl.d/99-devspoon-web.conf
net.core.somaxconn = 4096
net.ipv4.tcp_max_syn_backlog = 4096
net.ipv4.ip_local_port_range = 10000 65535
vm.overcommit_memory = 1 # recommended for Redis
fs.file-max = 200000
# apply
sudo sysctl --system- ulimit: set
nofile=65536through the docker daemon'sdefault-ulimits. - The 8 GB RAM ceiling: keep 2–4 GB of swap as OOM protection during memory spikes.
| Symptom | Likely cause | What to do |
|---|---|---|
| Container OOM kill | Total worker memory > 6 GB | Watch RSS with docker stats, then lower workers or max_requests |
| Workers restarting unexpectedly | max_requests reached, or harakiri/timeout |
Look for "Worker timeout" in the error log. Move long-running work into celery |
| Intermittent 502 Bad Gateway | Upstream shutdown timing vs nginx keepalive | Check that gunicorn's keepalive is shorter than nginx's keepalive_timeout |
| celery memory growth | Memory leak in the prefork workers | Confirm --max-tasks-per-child=2000 is in effect. Library memory fragmentation (numpy/pandas especially) is a possibility |
| logrotate not running | Wrong host path, or cron not installed | Check the folder name script/logrotate (note the s) and that the container's cron daemon is running |
| certbot renewal failing | webroot permissions, DNS change, port 80 blocked | Check /log/nginx/crontab_*.log. Manual dry run: certbot renew --dry-run |
[emerg] host not found in upstream "<app>" in the webserver log (webserver exits once, then restarts itself) |
nginx resolves the app container names in the conf when it starts. If nginx comes up before the app (host reboot, docker compose start), or a whole-project docker compose restart restarts everything at once and nginx comes up as the app goes down, the name does not exist |
On the startup side, the nginx image's /docker-entrypoint.d/30-wait-upstreams.sh waits up to NGINX_UPSTREAM_WAIT seconds (default 30, adjustable through the webserver environment, 0 disables it) for the names to resolve — this needs an image rebuild (--build). It cannot fully cover a whole-project restart, where the app can go down after the hook has checked, so apply config changes with nginx -s reload or docker compose restart webserver, and restart everything with stop → start. If the message keeps appearing, check for a mismatch between the app name in the conf and the compose container_name/alias |
DB errors after preload_app=True |
DB connection shared across forks | Call connections.close_all() (Django) or recreate the engine in a post_fork hook |
# 1. pull the new code
git pull origin main
# 2. build if a Dockerfile changed (skip otherwise)
cd compose/web-service/nginx_<service>
docker compose build --no-cache <service>-app # only the service that needs it
# 3. stop, including profile services — the celery profile for Python stacks and the redis profile
# for PHP stacks. A stop without the profile leaves them running. A profile name that does not
# exist in the stack is ignored, so the same command works for both stacks
docker compose --profile celery --profile redis stop
# 4. start
docker compose --profile celery --profile redis up -d
# 5. health check
docker compose ps
docker compose logs --tail=100 -f <service>-app
# 6. external health check
curl -fsS https://<domain>/health || echo "FAIL"Do not use
docker compose down— it removes some network and volume metadata with it, which can cost you a rebuild of the SSL and redis data. In particular,docker compose down -vdeletes the named volumeapp-data(the SQLite database, §0.6.2).
- Never commit the secrets in
.env(DJANGO_SECRET_KEY,REDIS_PASSWORD,FLOWER_ID,FLOWER_PWD). Check the**/.envpattern in.gitignore(§0.5.1). Generate secrets withensure_env_secrets(§0.6.1).CELERY_BROKER_URLis not in .env — compose composes it from REDIS_PASSWORD (§0.5.3). -
flowerbinds to127.0.0.1:5555only — reach it through an SSH tunnel (ssh -L 5555:127.0.0.1:5555 <host>) and do not change it to an external bind (FLOWER_BASIC_AUTHis enforced, but it is a single line of defence, §0.6.6).redis-statswas removed, so adopt RedisInsight or redis_exporter if you need monitoring (§0.5.7). - Keep redis on the internal container network only (no host port exposed is the default — keep it that way).
protected-mode yes+requirepassare enforced, so even containers on the same network must authenticate (§0.5.2). - When building the Python base image, re-confirm once a quarter that
docker build --build-arg PYTHON_SHA256=<official>or the ARG default indocker/gunicorn/Dockerfilematches the official python.org hash (§0.5.4). - Review the cron time in
docker/nginx/Dockerfile(Mondays 05:00) against your own traffic pattern and pick a quiet window. - Monitor log disk usage — alert when
df -h log/reaches 80 %. - In
ufwor the cloud firewall, expose only 80/tcp and 443/tcp and block everything else (flower's 5555 binds to host localhost, so it needs no firewall rule). - Keep OS time in sync (
chronyorsystemd-timesyncd) — certbot, cron and log timestamps depend on it.
This project manages the Python dependencies of www/django_sample with uv, but deliberately does not create a separate virtualenv (.venv) inside the containers. The container is already the isolation boundary, so a venv is a redundant extra layer that only complicates troubleshooting.
docker/gunicorn/Dockerfileanddocker/uwsgi/Dockerfilehard-code these ENVs:UV_PROJECT_ENVIRONMENT=/usr/local UV_LINK_MODE=copy UV_COMPILE_BYTECODE=1 UV_NO_CACHE=1- Because of that,
uv syncinside the container creates no.venvand installs straight into/usr/local/lib/python3.14/site-packages(the system Python). - The compose
command:calls the system binaries (gunicorn,daphne,uwsgi,celery) directly rather than going throughuv run. - The
--inexactflag protects the packages the Dockerfile pre-installed (fastapi, sqlalchemy, wheel and so on) from being removed.
| Item | With a venv | This project (system install) |
|---|---|---|
| Debugging an import | uv run python -c "import django" |
python -c "import django" |
| Listing packages | uv pip list --python .venv/bin/python |
pip list |
| Host directory | www/<project>/.venv/ appears on the host |
The host keeps only source, staying clean |
| Cross-volume hardlinks | Frequent conflicts → need UV_LINK_MODE=copy to work around |
Same layer, so no effect |
| Two venvs in one container | Possible, and confusing | One system site-packages → no ambiguity |
UV_PROJECT_ENVIRONMENT does not exist on the host, so cd www/django_sample && uv sync --extra celery creates a .venv automatically (--extra celery is needed to run manage.py on the host, because of django_celery_beat in settings). Host and container share the same pyproject.toml and differ only in where packages land.
# on the host (development machine):
cd www/django_sample
uv add requests # add a runtime dep → updates pyproject.toml + uv.lock
uv add --optional celery django-celery-results # add to an extra → [project.optional-dependencies].celery
uv add --dev pytest-mock # add a dev dep
uv lock # regenerate the lock only (if needed)
# commit the change:
git add pyproject.toml uv.lock
git commit -m "deps: add requests"
# restart the containers → uv sync runs at startup and applies it to the system Python
# (the app image's pre-installed set is also derived from uv.lock, hence --build, §0.6.4):
cd ../../compose/web-service/nginx_<service>
docker compose --profile celery stop && docker compose up -d --build
docker compose --profile celery up -d # if you use celery (a stop without the profile leaves celery running)# 1) check what is installed inside the container — no venv activation needed
docker exec -it gunicorn-app pip list | grep -i django
# 2) drop straight into a Django ORM shell
docker exec -it gunicorn-app python manage.py shell
# 3) confirm where uv sync actually installs
docker exec -it gunicorn-app uv pip list --system
docker exec -it gunicorn-app python -c "import django; print(django.__file__)"
# → /usr/local/lib/python3.14/site-packages/django/__init__.pyEvery Python container in the same compose stack (app + celery + celerybeat) must uv sync with exactly the same set of extras. They share the system site-packages, so a container syncing with different extras can remove another container's packages. --inexact is a first safety net, but keep the extras combination consistent.
| Change | Rebuild needed? | Container restart needed? |
|---|---|---|
docker/<service>/Dockerfile |
✅ docker compose build |
✅ |
config/app-server/*/... (conf.py, ini) |
❌ (volume-mounted) | ✅ (reload workers) |
config/web-server/nginx/... (managed separately) |
❌ | nginx -s reload on the nginx container only |
compose/.../docker-compose.yml |
Depends on the change | ✅ |
script/logrotate/* |
❌ | ❌ (applies from the next cron tick) |
script/letsencrypt.sh |
❌ | ❌ |
script/test/* |
❌ | ❌ (host-side manual verification assets, unrelated to production) |
The scripts in script/test/ are unrelated to the production images and stacks. Nothing in the Dockerfiles or compose files references them, and they never go inside a container. They are assets a developer runs directly on the host (assumed to be WSL2 Ubuntu + Docker) to check consistency and catch regressions. Deleting the folder has no effect on the running services — it is purely a regression-testing convenience.
| File | Kind | Duration | Changes containers? |
|---|---|---|---|
preflight.sh |
Environment pre-check (read-only) | ~30 s | None |
verify-ngxblocker.sh |
End-to-end ngxblocker verification | 1–3 min | Starts the gunicorn stack's webserver and redis under a dedicated compose project (-p devspoon-ngxb-test), then down -v on that project only when it finishes (stop the production stack first), plus adding and removing a temporary conf (the script cleans up after itself) |
A read-only script that confirms in about 30 seconds that the host meets the requirements, before you start a test or production setup. It creates and changes nothing, and starts no containers.
Usage
cd /path/to/devspoon-web # repository root
bash script/test/preflight.shExit codes: 0 = PREFLIGHT PASS (safe to start testing / deploying), 1 = PREFLIGHT FAIL (something is missing — fix the [MISS] lines on screen and run it again).
When to run it
- Right after setting up a new dev environment (WSL2 / cloud VM) — confirm no required tool is missing.
- Right after a major OS or Docker update — catch regressions such as the
docker compose v2migration. - In the first 30 minutes of onboarding a new team member — it cuts down the "why doesn't this work?" round trips.
- As the first step of a CI workflow — it makes the dependencies visible (the script doubles as environment documentation).
What it checks (four categories)
| Category | Example items | On failure |
|---|---|---|
[1] Required tools |
docker (>=24), docker compose (v2), jq, curl, openssl, wrk (optional) |
[MISS] — install that tool |
[2] Repository files |
A .env for each of the stacks (gunicorn / uvicorn / daphne / uwsgi / nginx_php-7.3 / nginx_php-8.4), four kinds of Dockerfile (both php-fpm 7.3 and 8.4), the entrypoints, pyproject.toml, script/letsencrypt.sh |
[MISS] — repository integrity is broken. Re-clone or check git status |
[3] Design invariants |
The folder name script/logrotate (not the typo 'loglotate'), log/.gitkeep × 11, whether pyproject.toml is PEP 621, UV_PROJECT_ENVIRONMENT=/usr/local, FROM base consistency, absence of a service nginx restart regression, uwsgi.ini py-autoreload=0 and so on |
[MISS] — a deliberate design decision was broken. See §0, §6 and §8 for the reasoning |
[4] Host environment |
Whether this is WSL2, 20 GB+ free disk, net.core.somaxconn 4096+ |
[WARN] — informational. Recommended in production, ignorable in dev |
The service nginx restart check in [3] is a static grep that guards against restarting nginx with service inside a container, which kills the whole container when PID 1 is nginx.
Verifies end to end that nginx-ultimate-bad-bot-blocker downloads, integrates and actually blocks. It uses the gunicorn stack (compose/web-service/nginx_gunicorn) as the test bed — not the PHP stacks, since every stack shares the same nginx image and verifying one is enough.
Usage
cd /path/to/devspoon-web # repository root
bash script/test/verify-ngxblocker.shExit codes: 0 = ALL CHECKS PASSED, 1 = at least one failure (look at the [FAIL] lines). On success the last line is a green ALL CHECKS PASSED.
Important side effects — the state it leaves behind
This script is not read-only. It touches the environment in this order:
- Starts the gunicorn stack's
webserver redisunder a dedicated compose project (-p devspoon-ngxb-test) with a temporary env-file (IMAGE_NAMESPACE=devspoon-it), and cleans up only that project withdown -v --remove-orphanswhen it finishes — your production.envand the production project'sapp-datavolume are untouched. Butcontainer_nameand host ports 80/443 are fixed, so a running production gunicorn stack collides:docker compose stopit first. - Adds a temporary conf inside the container (
/etc/nginx/conf.d/zz_blocker_test.conf) — a server block matchingHost: blocker.test. TheCleanupstep removes it and reloads just before the script exits. - Temporary log files (
/log/nginx/blocker_test_access.log,blocker_test_error.log) — these stay in the host volume (clean up manually if you care). - Calls
update-ngxblocker -c /etc/nginxmanually — this refreshes globalblacklist.conf and updates its mtime.
Running it directly on a live host causes brief downtime for the gunicorn stack, so only run it during a maintenance window on a server carrying production traffic.
When to run it
| Trigger | Why |
|---|---|
Changing the zone name, key, size or rate of limit_conn_zone / limit_req_zone ($bot_iplimit) in nginx.conf |
A rate-limit policy change can regress the bot-blocking integration |
Right after a manual update-ngxblocker for bots.d/ddos.conf or globalblacklist.conf |
Manual refreshes outside the six-hourly cron carry a higher regression risk |
Moving the ngxblocker include lines (blockbots.conf / ddos.conf) in sample_nginx*.conf, or adding another bots.d/* file |
Confirms the server-context includes went to the right place |
Rebuilding docker/nginx/Dockerfile (changes to install-ngxblocker, ca-certificates, cron setup and so on) |
Confirms every baked-in artefact is present |
| Restarting after six months or more without a build or verification | Upstream (mitchellkrogza/nginx-ultimate-bad-bot-blocker) can change its format subtly and break the grep patterns |
Verification steps (A–E)
| Step | What it looks at |
|---|---|
| A Start the stack | up -d webserver redis under the dedicated project, nginx -t passes (only that project is down -v'd at the end) |
| B Downloaded artefacts | globalblacklist.conf ≥ 400 KB, 1000+ bot regex patterns, eight known bots present (MJ12, Ahrefs, Semrush, DotBot, BLEX, Scrapy, nikto, sqlmap), all nine bots.d/ files exist |
| C nginx integration | nginx.conf includes globalblacklist.conf exactly once, $bad_bot is defined, nginx -t syntax/test OK, master + ≥ 2 workers |
| D cron / update | The update-ngxblocker -c /etc/nginx line is in crontab, the cron daemon runs, a manual update leaves the file intact and the workers healthy after reload |
| E End-to-end blocking | With the temporary server block (blocker.test) — Mozilla UA → 200, four bot UAs (MJ12 / Ahrefs / Semrush / BLEX) → 444 / 000 / 403, bad referer (semalt.com) blocked (optional), access log written |
In E-4 the bot UA sometimes shows 000 instead of 444. That means nginx closed the connection without a response and curl received nothing — it counts as the same PASS.
| Scenario | Command |
|---|---|
| First diagnosis after setting up a new dev environment | bash script/test/preflight.sh |
| Regression check right after an OS / Docker update | bash script/test/preflight.sh |
After changing the rate limits in nginx.conf or anything in bots.d/* |
bash script/test/verify-ngxblocker.sh |
After rebuilding docker/nginx/Dockerfile |
bash script/test/verify-ngxblocker.sh |
| Both in sequence (setup → bot-blocking verification) | bash script/test/preflight.sh && bash script/test/verify-ngxblocker.sh |
When you need a new regression asset, add it to the same folder as verify-<topic>.sh or preflight-<topic>.sh to keep the naming consistent.
Where script/test/ (§10) is for "verify one area quickly", script/test_run/ is an end-to-end regression battery ordered by stage number (s0/s1b/s2/s3/s5/s6). It lets you run the same regression scenario over the whole stack repeatedly.
| Stage | Script | Area verified | Notes |
|---|---|---|---|
| s0 | s0_prereq.sh |
docker / docker compose versions, host ports (80/443/5555) free, log/<service>/ present, uv installed, www/django_sample uv sync |
Pre-check (close to read-only) |
| s1b | s1b_exit_check.sh |
Exit codes and log patterns when a container dies abnormally | Diagnostic |
| s1b | s1b_nginx_conf_generators.sh |
Whether nginx_http_conf.sh / nginx_https_conf.sh produce correct output for all five stacks (gunicorn / uvicorn / uwsgi / php-7.3 / php-8.4) — missing substitutions, empty placeholders, file permissions |
conf generator regression |
| s2 | s2_build.sh |
Builds each stack's Dockerfile in isolation with docker build -f (both php-fpm 7.3 and 8.4 variants), tagged devspoon-test/* |
Build regression, independent of the compose layer |
| s2a | s2a_image_inspect.sh |
Base-layer consistency of the built images, ENV / WORKDIR / CMD | Image metadata |
| s3 | s3_stack_smoke.sh <stack> <appname> <appcontainer> <stack_name> |
Start one stack → nginx -t → curl -H "Host: ..." returns 200 → cleanup |
Per-stack smoke test |
| s5 | s5_https.sh <stack> |
The HTTPS side: dhparam creation/mount/restore, self-signed certificate generation, verification of the substituted sample_nginx_https.conf, nginx -t passing — certbot issuance excluded (assumes an environment with no domain) |
HTTPS consistency |
| s6 | s6_regression.sh |
Integrated regression — calls every stage above in order and prints a combined result | Nightly regression |
| Helper | ssl_diag.sh |
Checks the dhparam path and contents, and that the host backup matches the container's copy | Automates the dhparam persistence section of §3 |
| Helper | verify_block.sh |
Checks bot/scanner blocking (partly overlapping verify-ngxblocker in §10.2) | |
| Helper | verify_compose_yml.sh |
Static check of the six stacks' docker-compose.yml for the dhparam mount and anti-patterns (mounting ssl/certs, undefined ulimits) | Static regression |
| Helper | verify_dhparam_lifecycle.sh / verify_dhparam_host_wins.sh |
dhparam stages A/B/C — backup, restore and host-wins verification (randomised PORT, stronger polling) | §3 dhparam |
| Helper | verify_healthcheck.sh |
Static verification of the app/webserver healthchecks and depends_on: service_healthy across the six stacks, plus one stack at runtime (nginx_php-8.4 by default; run-ci does the static part only) |
§0.5 healthcheck |
| Helper | verify_nginx_standalone.sh |
Starts a real nginx container with every mount and extracts dhparam with docker cp even from a stopped container |
dhparam integration |
| Integration | verify_integration_<stack>.sh × 6 |
Full-stack integration verifiers for all six stacks — compose up --wait, HTTP 200 (Host: localhost), 403 on dotfiles, bot UA blocked, rapid requests from a normal UA not blocked, app healthy over a stabilisation window (STABLE_WINDOW), celery and beat started with a broker ping on Python stacks, DEBUG off, and more — see the check lines in each script for the per-stack assertions |
gunicorn / uvicorn / uwsgi / daphne / php73 / php84 |
| Helper | celery_diag.sh / check_cgi.sh / check_cgi2.sh / check_excode.sh / inspect_orphans.sh / sim_exit.sh |
Individual diagnostic helpers | One-off |
# stage by stage (s0 → s2 → s3 → s5 → s6)
cd /path/to/devspoon-web # repository root
bash script/test_run/s0_prereq.sh
# smoke-test one stack (gunicorn)
bash script/test_run/s3_stack_smoke.sh nginx_gunicorn gunicorn gunicorn-app gunicorn
# HTTPS verification for one stack (no domain, certbot excluded)
# arguments: STACK_DIR STACK WEBROOT APPNAME SERVICE_PORT
bash script/test_run/s5_https.sh nginx_gunicorn gunicorn django_sample gunicorn-app 8000
# dhparam persistence verification (automates the dhparam section of §3)
bash script/test_run/ssl_diag.sh
# full regression
bash script/test_run/s6_regression.sh
# === full-stack integration verifiers (6 stacks, end-to-end) ===
# automatic .env setup + compose up + wait for healthcheck + HTTP 200 + gzip + privilege drop + dhparam
bash script/test_run/verify_integration_gunicorn.sh
bash script/test_run/verify_integration_uvicorn.sh
bash script/test_run/verify_integration_uwsgi.sh
bash script/test_run/verify_integration_daphne.sh
bash script/test_run/verify_integration_php73.sh
bash script/test_run/verify_integration_php84.sh- The scripts compute
ROOTfrom their own location (script/test_run/../..), so they run from any clone path. s5_https.shassumes a local environment with no domain — it never attempts certbot issuance and only verifies dhparam, the nginx https sample and path consistency.s3_stack_smoke.shstarts and stops containers, so only run it during a maintenance window on a production host.- On a WSL2 host you may need to re-check
chmod 644 compose/web-service/*/redis/conf/redis.conf(and the other bind-mount targets) right before runningscript/test_run/*.sh. WSL's defaultfmask=177policy truncates them to 0600, and then redis inside the container cannot read its conf. For a permanent fix, see the/etc/wsl.confsettings in §11 (WSL2 host operations guide).
This project broadly assumes a dev scenario of Windows + WSL2 (Ubuntu) running docker. WSL2's default mount options repeatedly truncate the permissions of bind-mounted paths and break things, so this section collects the fixes.
For production, use native Linux or a cloud VM where possible. WSL2 is for dev and verification.
WSL2 mounts /mnt/c with fmask=177 by default (0600 — owner read/write only). With that, the container cannot read any bind-mounted file — redis.conf (needs 0644), www/php_sample/index.php (needs 0644), nginx.conf and the rest — and exits immediately ("Permission denied", "Failed to open log file" and similar).
Add this to /etc/wsl.conf in the WSL2 instance (create the file if it does not exist).
[automount]
enabled = true
options = "metadata,umask=22,fmask=11"metadata: lets the Linux side store chmod/chown metadata on Windows NTFS.umask=22: directories default to 0755;fmask=11: files default to 0644 (so mounted files appear as 0644).
Then, from PowerShell:
wsl --shutdown
# restart the WSL terminal → the new mount options applyVerify:
mount | grep '/mnt/c'
# → the options should show "umask=22,fmask=11"
ls -l compose/web-service/nginx_gunicorn/redis/conf/redis.conf
# → should show -rw-r--r-- (0644)Under WSL, the container's dhparam backup hook writes into the host directory (./ssl/dhparam/), and the file ends up owned by root inside the container. A non-root host user cannot edit it directly. When you need to change it:
# work inside the container
docker compose exec webserver sh -c 'rm /etc/nginx/dhparam-backup/dhparam.pem'
# or use sudo on the WSL host
sudo rm compose/web-service/nginx_gunicorn/ssl/dhparam/dhparam.pemThe 9P/Plan9 mount at /mnt/c is roughly 10× slower for IO than native ext4. It is the main reason container builds drag during development — more a productivity tip than an operations rule, but move the project to ~/projects/devspoon-web (WSL2 native ext4) if you can (the scripts compute ROOT automatically).
On WSL2 the time from container start to the first successful healthcheck can be longer than on native Linux. compose's start_period: 30s leaves some margin, but it may not be enough when the WSL host is under memory pressure. In that case the webserver fails with dependency failed to start: container ... is unhealthy — raise the timeout to around 60s once, then put it back when things settle.
- Website : Owner's personal website is devspoon.com
- Lim Do-Hyun Owner Developer/project Manager, bluebamus@gmail.com