Skip to content

Commit be42969

Browse files
committed
fix(nginx): a missing certificate no longer stops the server
Without certificates/php-devforge.pem nginx refused to start and the container restarted forever, while Apache in the same state generated a self-signed certificate and served with a browser warning. Since task 38 made 'forge profile on nginx' a documented command, that is a state people will reach. nginx has no <IfFile>, so an entrypoint picks the certificate and hands it to the template as $SSL_CERT / $SSL_KEY. It also absorbed the envsubst calls inlined in CMD, which is now just openresty. Kept separate from Apache's copy on purpose: sharing twelve lines of openssl would mean widening both build contexts to ./docker-library. Planning turned up a second hole -- Apache's fallback had never been tested either, because CI always runs install_cert.sh first. The new step covers both, and checks the certificate's issuer rather than trusting 'curl -k', which accepts anything. Verified the step fails with the fix reverted, and that its trap puts the certificates back when it dies halfway.
1 parent 9cac1fd commit be42969

5 files changed

Lines changed: 153 additions & 16 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ jobs:
2121
shellcheck --severity=warning \
2222
install.sh install_cert.sh setup-local-dns.sh aliases.bash \
2323
lib/menu.sh lib/compose.sh bin/forge \
24+
docker-library/nginx/config_files/docker-entrypoint.sh \
2425
docker-library/php/config_files/docker-php-entrypoint \
2526
docker-library/php/config_files/xdebug \
2627
docker-library/httpd/config_files/docker-entrypoint.sh
@@ -523,6 +524,67 @@ jobs:
523524
forge profile off nginx
524525
docker compose ps --services | grep -qx apachedev
525526
527+
# Neither fallback had ever been exercised: CI runs install_cert.sh first, so
528+
# the certificate always existed. nginx died outright without one, and
529+
# Apache's safety net -- written in task 7 -- had never been proven to work.
530+
- name: a missing certificate degrades instead of killing the server
531+
run: |
532+
set -euo pipefail
533+
export PATH="$HOME/.local/bin:$PATH"
534+
mkdir -p /tmp/certs-aside
535+
mv certificates/php-devforge.pem certificates/php-devforge.key /tmp/certs-aside/
536+
# Unconditional: a failure halfway through would otherwise leave the
537+
# certificates aside, and every later step -- including the log dump on
538+
# failure -- would be looking at a degraded stack.
539+
trap 'mv /tmp/certs-aside/* certificates/ 2>/dev/null || true' EXIT
540+
541+
# `curl -k` accepts any certificate, so it cannot tell a generated
542+
# fallback from a stale file. The issuer can: a self-signed one signs
543+
# itself, mkcert's does not.
544+
served_by() {
545+
echo | openssl s_client -connect 127.0.0.1:443 -servername "welcome.phpforge.dev" 2>/dev/null \
546+
| openssl x509 -noout -issuer
547+
}
548+
549+
docker compose up -d --force-recreate apachedev
550+
for i in $(seq 1 30); do
551+
curl -fsS -k --max-time 5 --resolve welcome.phpforge.dev:443:127.0.0.1 \
552+
https://welcome.phpforge.dev/ >/dev/null 2>&1 && break
553+
sleep 1
554+
done
555+
curl -fsS -k --resolve welcome.phpforge.dev:443:127.0.0.1 \
556+
https://welcome.phpforge.dev/ | grep -q "PHP DevForge is running"
557+
served_by | grep -q 'CN=phpforge.dev' || {
558+
echo "apache is not serving the generated fallback: $(served_by)"; exit 1; }
559+
docker compose logs apachedev | grep -qi 'self-signed'
560+
echo "apache degrades"
561+
562+
forge profile on nginx
563+
for i in $(seq 1 30); do
564+
curl -fsS -k --max-time 5 --resolve welcome.phpforge.dev:443:127.0.0.1 \
565+
https://welcome.phpforge.dev/ >/dev/null 2>&1 && break
566+
sleep 1
567+
done
568+
docker compose ps --services | grep -qx nginxdev || {
569+
echo "nginxdev is not running"; docker compose logs --tail=20 nginxdev; exit 1; }
570+
curl -fsS -k --resolve welcome.phpforge.dev:443:127.0.0.1 \
571+
https://welcome.phpforge.dev/ | grep -q "PHP DevForge is running"
572+
served_by | grep -q 'CN=phpforge.dev' || {
573+
echo "nginx is not serving the generated fallback: $(served_by)"; exit 1; }
574+
echo "nginx degrades too"
575+
576+
# Put it back, or later steps would be testing the wrong certificate.
577+
forge profile off nginx
578+
mv /tmp/certs-aside/php-devforge.pem /tmp/certs-aside/php-devforge.key certificates/
579+
docker compose up -d --force-recreate apachedev
580+
for i in $(seq 1 30); do
581+
curl -fsS --max-time 5 --resolve welcome.phpforge.dev:443:127.0.0.1 \
582+
https://welcome.phpforge.dev/ >/dev/null 2>&1 && break
583+
sleep 1
584+
done
585+
served_by | grep -q 'mkcert' || { echo "the real certificate did not come back"; exit 1; }
586+
echo "and the real one comes back"
587+
526588
- name: container logs on failure
527589
if: failure()
528590
run: docker compose logs --tail=50

‎TASKS.md‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1292,7 +1292,7 @@ what the README explains first.
12921292
Both surfaced from running the CI steps against running containers rather than
12931293
reasoning about them.
12941294
1295-
- [ ] **39. nginx dies when there is no certificate; Apache degrades**
1295+
- [x] **39. nginx dies when there is no certificate; Apache degrades** — FIXED
12961296
A checkout without `certificates/php-devforge.pem` — a fresh clone, or one where
12971297
`install_cert.sh` was skipped — cannot run the nginx variant at all:
12981298
@@ -1318,6 +1318,36 @@ reasoning about them.
13181318
CI never caught this because it runs `install_cert.sh` before the nginx step,
13191319
so the certificate always exists there.
13201320
1321+
Fixed with an entrypoint mirroring Apache's. nginx has no `<IfFile>`, so the
1322+
choice is made in the script and handed to the template as `$SSL_CERT` /
1323+
`$SSL_KEY` — the templates were already rendered with `envsubst`, so this adds
1324+
two variables to a list that exists to keep nginx's own `$mail` and
1325+
`$subdomains` from being substituted. The entrypoint also absorbed the
1326+
`envsubst` calls that were inlined in `CMD`, which is now just
1327+
`openresty -g 'daemon off;'`.
1328+
1329+
Kept as a second copy rather than shared with Apache's: the common part is
1330+
twelve lines of `openssl`, and sharing means widening both build contexts to
1331+
`./docker-library`, shipping every other image's files as context.
1332+
1333+
**The planning turned up a second hole: Apache's fallback had never been
1334+
tested either.** Nothing in CI removed the certificates, so the safety net
1335+
written in task 7 had been assumed to work for weeks. It does — verified for
1336+
the first time here — but that is luck, not coverage. The new step exercises
1337+
both servers.
1338+
1339+
It checks the certificate's **issuer**, not just that HTTPS answers: `curl -k`
1340+
accepts anything, so an assertion built on it would pass even if the fallback
1341+
were never generated and a stale file were being served. A self-signed
1342+
certificate has issuer equal to subject; mkcert's does not.
1343+
1344+
Two things proven rather than assumed:
1345+
1346+
| | |
1347+
|---|---|
1348+
| the step fails without the fix | reverted the entrypoint, got `nginxdev is not running` |
1349+
| the cleanup survives a failure | a `trap ... EXIT` puts the certificates back even when the step dies halfway, which the first version did not — it left every later step testing a degraded stack |
1350+
13211351
- [ ] **40. Two checkouts of the project fight over the same containers**
13221352
`COMPOSE_PROJECT_NAME=php-devforge` is a fixed literal in `.env.example:4`, so
13231353
every clone claims the same compose project. Seen for real with a second

‎docker-library/nginx/Dockerfile‎

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,11 @@ COPY config_files/config.main /etc/nginx/conf.d/config.main
3232
# 3. Crear carpeta de certificados
3333
RUN mkdir -p /etc/nginx/ssl/
3434

35-
# 4. CMD: Procesa ambos templates con envsubst y luego inicia OpenResty
36-
CMD ["/bin/bash", "-c", " \
37-
# Procesa el archivo principal de Nginx
38-
envsubst '$DEV_DOMAIN $PHP_VERSION' < /etc/nginx/conf.d/site.conf.tpl > /etc/nginx/conf.d/site.conf && \
39-
\
40-
# Procesa el fragmento de configuración común y lo guarda donde se incluye
41-
envsubst '$DEV_DOMAIN $PHP_VERSION' < /etc/nginx/conf.d/common_server_config.conf.tpl > /etc/nginx/snippets/common_server_config.conf && \
42-
\
43-
# Inicia OpenResty
44-
openresty -g 'daemon off;' \
45-
"]
35+
# The entrypoint renders the templates and picks the certificate: the real one if
36+
# ./certificates has it, a self-signed one it generates otherwise. Without it a
37+
# missing certificate stopped nginx from starting at all.
38+
COPY config_files/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
39+
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
40+
41+
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
42+
CMD ["openresty", "-g", "daemon off;"]
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#!/bin/bash
2+
#
3+
# Generates a temporary self-signed certificate when the real one is missing, so
4+
# nginx still starts. The browser will warn, but the environment stays usable.
5+
#
6+
# nginx has no equivalent of Apache's <IfFile>, so the choice is made here and
7+
# handed to the template as $SSL_CERT / $SSL_KEY.
8+
#
9+
# docker-library/httpd/config_files/docker-entrypoint.sh does the same job for
10+
# Apache. The two are kept separate on purpose: sharing twelve lines of openssl
11+
# would mean widening both build contexts to ./docker-library, which ships every
12+
# other image's files as context and churns the cache on unrelated edits.
13+
#
14+
set -e
15+
16+
REAL_CERT="/etc/nginx/ssl/php-devforge.pem"
17+
REAL_KEY="/etc/nginx/ssl/php-devforge.key"
18+
FALLBACK_DIR="/etc/nginx/ssl-fallback"
19+
DOMAIN="${DEV_DOMAIN:-localhost}"
20+
21+
if [ -s "$REAL_CERT" ]; then
22+
export SSL_CERT="$REAL_CERT"
23+
export SSL_KEY="$REAL_KEY"
24+
else
25+
echo "⚠️ $REAL_CERT not found"
26+
echo " Generating a temporary self-signed certificate for *.${DOMAIN}"
27+
echo " Your browser will warn that it is not trusted."
28+
echo " Run ./install_cert.sh on the host to get the real one."
29+
30+
mkdir -p "$FALLBACK_DIR"
31+
openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
32+
-subj "/CN=${DOMAIN}" \
33+
-addext "subjectAltName=DNS:${DOMAIN},DNS:*.${DOMAIN}" \
34+
-keyout "${FALLBACK_DIR}/fallback.key" \
35+
-out "${FALLBACK_DIR}/fallback.pem" >/dev/null 2>&1
36+
chmod 600 "${FALLBACK_DIR}/fallback.key"
37+
38+
export SSL_CERT="${FALLBACK_DIR}/fallback.pem"
39+
export SSL_KEY="${FALLBACK_DIR}/fallback.key"
40+
fi
41+
42+
# The variable list is explicit so nginx's own $mail and $subdomains survive.
43+
envsubst '$DEV_DOMAIN $PHP_VERSION $SSL_CERT $SSL_KEY' \
44+
< /etc/nginx/conf.d/site.conf.tpl > /etc/nginx/conf.d/site.conf
45+
envsubst '$DEV_DOMAIN $PHP_VERSION $SSL_CERT $SSL_KEY' \
46+
< /etc/nginx/conf.d/common_server_config.conf.tpl > /etc/nginx/snippets/common_server_config.conf
47+
48+
exec "$@"

‎docker-library/nginx/config_files/site.conf.tpl‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ server {
2727
listen 443 ssl;
2828
http2 on;
2929
server_name mail.${DEV_DOMAIN};
30-
ssl_certificate /etc/nginx/ssl/php-devforge.pem;
31-
ssl_certificate_key /etc/nginx/ssl/php-devforge.key;
30+
ssl_certificate ${SSL_CERT};
31+
ssl_certificate_key ${SSL_KEY};
3232
location / {
3333
resolver 127.0.0.11 ipv6=off valid=10s;
3434
set $mail "mailpit:8025";
@@ -50,7 +50,7 @@ server {
5050
listen 443 ssl;
5151
http2 on;
5252
server_name ~^(?<subdomains>.+)\.${DEV_DOMAIN}$;
53-
ssl_certificate /etc/nginx/ssl/php-devforge.pem;
54-
ssl_certificate_key /etc/nginx/ssl/php-devforge.key;
53+
ssl_certificate ${SSL_CERT};
54+
ssl_certificate_key ${SSL_KEY};
5555
include /etc/nginx/snippets/common_server_config.conf;
5656
}

0 commit comments

Comments
 (0)