Add isolated Caddy edge for production and staging
Some checks are pending
Quality / full-local-gates (push) Waiting to run

This commit is contained in:
SimpleTest 2026-07-21 03:34:37 +03:00
parent e2ea2252bc
commit 87e6667e6a
17 changed files with 486 additions and 1 deletions

View File

@ -81,3 +81,4 @@ core.*
/.env
/.env.*
!/.env.example
!/.env.edge.example

10
.env.edge.example Normal file
View File

@ -0,0 +1,10 @@
# Copy to an ignored mode-0600 .env.edge on the public server.
COMPOSE_PROJECT_NAME=who_need_help_edge
PUBLIC_EDGE_NETWORK=who_need_help_public_edge
EDGE_BIND_ADDRESS=0.0.0.0
EDGE_HTTP_PORT=80
EDGE_HTTPS_PORT=443
PRIMARY_DOMAIN=whoneedhelp.com
PRIMARY_UPSTREAM=who-need-help-production:4000
STAGING_DOMAIN=staging.whoneedhelp.com
STAGING_UPSTREAM=who-need-help-staging:4000

View File

@ -22,6 +22,11 @@ HTTP_PORT=4010
# same host. The current VPN staging path needs an address reachable by its
# verified tunnel topology, so choose this per deployment.
HTTP_BIND_ADDRESS=0.0.0.0
# Attach the selected app service to the separately managed public Caddy
# network. Keep disabled for ordinary local development.
PUBLIC_EDGE_ENABLED=false
PUBLIC_EDGE_NETWORK=who_need_help_public_edge
PUBLIC_UPSTREAM_NAME=who-need-help-local
MAILPIT_PORT=8027
MAILPIT_BIND_ADDRESS=127.0.0.1
DOCKER_SOCKET_GID=REPLACE_WITH_DOCKER_SOCKET_NUMERIC_GID

1
.gitignore vendored
View File

@ -51,6 +51,7 @@ __pycache__/
/.env
/.env.*
!/.env.example
!/.env.edge.example
!/.env.load.example
!/.env.e2e.example

34
Dockerfile.caddy Normal file
View File

@ -0,0 +1,34 @@
# syntax=docker/dockerfile:1.20.0
FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS builder
ENV CGO_ENABLED=0
ENV GOTOOLCHAIN=local
RUN GOBIN=/out go install \
-trimpath \
-ldflags="-s -w -X github.com/caddyserver/caddy/v2.CustomVersion=v2.11.4" \
github.com/caddyserver/caddy/v2/cmd/caddy@v2.11.4
RUN mkdir -p /rootfs/data/caddy /rootfs/config/caddy /rootfs/tmp \
&& chown -R 1000:1000 /rootfs
FROM scratch
ENV XDG_CONFIG_HOME=/config
ENV XDG_DATA_HOME=/data
ENV HOME=/tmp
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /out/caddy /usr/bin/caddy
COPY --from=builder --chown=1000:1000 /rootfs/data /data
COPY --from=builder --chown=1000:1000 /rootfs/config /config
COPY --from=builder --chown=1000:1000 /rootfs/tmp /tmp
USER 1000:1000
WORKDIR /srv
EXPOSE 80 443 443/udp 2019
ENTRYPOINT ["/usr/bin/caddy"]
CMD ["run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]

View File

@ -65,6 +65,13 @@ policies are deliberately not claimed as complete.
## Fast start with Docker Compose
The public single-server path uses the compact application topology plus a
separately managed Caddy edge. Production and staging can run as isolated
Compose projects with distinct PostGIS volumes and secrets while sharing only a
Docker network used for HTTPS reverse proxying. See the
[operations runbook](docs/operations.md#public-https-edge-and-isolated-staging)
for the verified order of operations. Redis is not a project dependency.
Prerequisite: Docker with the Compose plugin.
```bash

46
compose.edge.yaml Normal file
View File

@ -0,0 +1,46 @@
name: who_need_help_edge
services:
edge:
image: ${CADDY_IMAGE:-who-need-help:caddy-local}
build:
context: .
dockerfile: Dockerfile.caddy
user: "1000:1000"
environment:
PRIMARY_DOMAIN: ${PRIMARY_DOMAIN:?Set PRIMARY_DOMAIN in the edge environment}
PRIMARY_UPSTREAM: ${PRIMARY_UPSTREAM:?Set PRIMARY_UPSTREAM in the edge environment}
STAGING_DOMAIN: ${STAGING_DOMAIN:?Set STAGING_DOMAIN in the edge environment}
STAGING_UPSTREAM: ${STAGING_UPSTREAM:?Set STAGING_UPSTREAM in the edge environment}
ports:
- "${EDGE_BIND_ADDRESS:-0.0.0.0}:${EDGE_HTTP_PORT:-80}:80"
- "${EDGE_BIND_ADDRESS:-0.0.0.0}:${EDGE_HTTPS_PORT:-443}:443"
- "${EDGE_BIND_ADDRESS:-0.0.0.0}:${EDGE_HTTPS_PORT:-443}:443/udp"
volumes:
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks: [public_edge]
extra_hosts:
- "host.docker.internal:host-gateway"
read_only: true
tmpfs:
- /tmp
cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE]
security_opt:
- no-new-privileges:true
restart: unless-stopped
healthcheck:
test: ["CMD", "caddy", "validate", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
interval: 10s
timeout: 3s
retries: 5
networks:
public_edge:
name: ${PUBLIC_EDGE_NETWORK:?Set PUBLIC_EDGE_NETWORK in the edge environment}
volumes:
caddy_data:
caddy_config:

17
compose.public-app.yaml Normal file
View File

@ -0,0 +1,17 @@
services:
app:
networks:
public_edge:
aliases:
- ${PUBLIC_UPSTREAM_NAME:?Set PUBLIC_UPSTREAM_NAME when PUBLIC_EDGE_ENABLED=true}
web:
networks:
public_edge:
aliases:
- ${PUBLIC_UPSTREAM_NAME:?Set PUBLIC_UPSTREAM_NAME when PUBLIC_EDGE_ENABLED=true}
networks:
public_edge:
name: ${PUBLIC_EDGE_NETWORK:?Set PUBLIC_EDGE_NETWORK when PUBLIC_EDGE_ENABLED=true}
external: true

27
deploy/caddy/Caddyfile Normal file
View File

@ -0,0 +1,27 @@
{
admin off
}
{$PRIMARY_DOMAIN} {
encode zstd gzip
header {
-Server
}
reverse_proxy {$PRIMARY_UPSTREAM}
}
www.{$PRIMARY_DOMAIN} {
redir https://{$PRIMARY_DOMAIN}{uri} permanent
}
{$STAGING_DOMAIN} {
encode zstd gzip
header {
-Server
}
reverse_proxy {$STAGING_UPSTREAM}
}

View File

@ -48,6 +48,67 @@ The deployment environment selects topology and database ownership:
There is no Redis dependency. Queues, rate-limit counters, Oban leadership,
and durable application state use PostgreSQL.
### Public HTTPS edge and isolated staging
The first single-server deployment uses one separately managed Caddy container
for ports 80/443 and two independent application projects. Caddy 2.11.4 is
built as a static non-root binary with pinned Go 1.26.5 and a scratch runtime.
Its persistent volume holds ACME account and
certificate state; do not remove that volume during an ordinary application
deploy. Production and staging each have their own application secret file,
PostGIS volume, migrations, and Docker DNS alias. They share only the named
`who_need_help_public_edge` network, so load and browser records created in
staging cannot enter the production database.
Generate and start the edge before either application project:
```bash
./scripts/init-edge-env.sh \
whoneedhelp.com staging.whoneedhelp.com .env.edge
./scripts/edge-up.sh .env.edge
```
The authoritative A records for `whoneedhelp.com`, `www.whoneedhelp.com`, and
`staging.whoneedhelp.com` must point to the observed server address, and inbound
TCP 80/443 plus UDP 443 must be permitted before public certificate issuance.
Caddy obtains and renews public certificates and redirects HTTP to HTTPS. The
Phoenix release remains plain HTTP on the internal shared Docker network;
Caddy replaces incoming forwarding headers and WebSocket proxying is automatic.
The compact app's optional host-published HTTP port stays bound to loopback for
operator health checks and is not a public entry point.
Create staging with independent names and secrets:
```bash
PRODUCTION_COMPOSE_PROJECT_NAME=who_need_help_staging \
PRODUCTION_PUBLIC_UPSTREAM_NAME=who-need-help-staging \
PRODUCTION_HTTP_PORT=4011 \
./scripts/init-production-env.sh \
staging.whoneedhelp.com .env.staging
```
Create production separately:
```bash
PRODUCTION_COMPOSE_PROJECT_NAME=who_need_help_production \
PRODUCTION_PUBLIC_UPSTREAM_NAME=who-need-help-production \
PRODUCTION_HTTP_PORT=4010 \
./scripts/init-production-env.sh \
whoneedhelp.com .env.production
```
Configure and validate SMTP/OAuth independently in each ignored file. Start
staging first, run database/load/browser/Android verification there, and then
start the clean production project. Stopping staging does not stop Caddy or
production:
```bash
./scripts/compose.sh .env.staging down
```
Do not add `--volumes` unless the exact staging database has been inspected and
its deletion is the intended operation.
Run the initializer on the target Docker host after its final public hostname
is known:

View File

@ -36,6 +36,7 @@ deployment_target=$(read_env_value DEPLOYMENT_TARGET 2>/dev/null || printf compo
deployment_environment=$(read_env_value DEPLOYMENT_ENV 2>/dev/null || printf development)
database_mode=$(read_env_value DATABASE_MODE 2>/dev/null || printf container)
app_topology=$(read_env_value APP_TOPOLOGY 2>/dev/null || printf split)
public_edge_enabled=$(read_env_value PUBLIC_EDGE_ENABLED 2>/dev/null || printf false)
project=$(read_env_value COMPOSE_PROJECT_NAME 2>/dev/null || printf who_need_help)
[[ "$deployment_target" == compose ]] || {
@ -70,6 +71,11 @@ case "$app_topology" in
*) echo "APP_TOPOLOGY must be compact or split." >&2; exit 1 ;;
esac
case "$public_edge_enabled" in
true | false) ;;
*) echo "PUBLIC_EDGE_ENABLED must be true or false." >&2; exit 1 ;;
esac
compose=(
docker compose
--project-directory "$ROOT"
@ -86,6 +92,10 @@ if [[ "$app_topology" == compact ]]; then
compose+=(--file "$ROOT/compose.compact.yaml" --profile compact)
fi
if [[ "$public_edge_enabled" == true ]]; then
compose+=(--file "$ROOT/compose.public-app.yaml")
fi
if [[ "$deployment_environment" == production ]]; then
compose+=(--file "$ROOT/compose.production.yaml")
fi

22
scripts/edge-up.sh Executable file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
env_file=${1:-"$ROOT/.env.edge"}
if [[ "$env_file" != /* ]]; then
env_file="$ROOT/$env_file"
fi
if [[ ! -f "$env_file" ]]; then
echo "Edge environment does not exist: $env_file" >&2
exit 1
fi
"$ROOT/scripts/validate-edge-env.sh" "$env_file"
exec docker compose \
--project-directory "$ROOT" \
--env-file "$env_file" \
--file "$ROOT/compose.edge.yaml" \
up -d --build --wait --remove-orphans

58
scripts/init-edge-env.sh Executable file
View File

@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -euo pipefail
umask 077
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
primary_domain=${1:-}
staging_domain=${2:-}
target=${3:-"$ROOT/.env.edge"}
if [[ -z "$primary_domain" || -z "$staging_domain" ]]; then
echo "Usage: $0 PRIMARY_DOMAIN STAGING_DOMAIN [OUTPUT_FILE]" >&2
exit 1
fi
if [[ "$target" != /* ]]; then
target="$ROOT/$target"
fi
if [[ -e "$target" ]]; then
echo "Refusing to overwrite existing edge environment: $target" >&2
exit 1
fi
target_dir=$(dirname -- "$target")
if [[ ! -d "$target_dir" ]]; then
echo "Output directory does not exist: $target_dir" >&2
exit 1
fi
tmp=$(mktemp "$target_dir/.edge-env.XXXXXX")
trap 'rm -f "$tmp"' EXIT HUP INT TERM
chmod 600 "$tmp"
PRIMARY_DOMAIN_VALUE=$primary_domain \
STAGING_DOMAIN_VALUE=$staging_domain \
awk '
BEGIN {
replacement["PRIMARY_DOMAIN"] = ENVIRON["PRIMARY_DOMAIN_VALUE"]
replacement["STAGING_DOMAIN"] = ENVIRON["STAGING_DOMAIN_VALUE"]
}
{
separator = index($0, "=")
key = separator > 1 ? substr($0, 1, separator - 1) : ""
if (key in replacement) {
print key "=" replacement[key]
} else {
print
}
}
' "$ROOT/.env.edge.example" >"$tmp"
mv "$tmp" "$target"
chmod 600 "$target"
trap - EXIT HUP INT TERM
"$ROOT/scripts/validate-edge-env.sh" "$target"
echo "Created mode-0600 edge environment: $target"

View File

@ -47,6 +47,9 @@ fi
database_mode=${PRODUCTION_DATABASE_MODE:-container}
app_topology=${PRODUCTION_APP_TOPOLOGY:-compact}
compose_project_name=${PRODUCTION_COMPOSE_PROJECT_NAME:-who_need_help}
public_edge_enabled=${PRODUCTION_PUBLIC_EDGE_ENABLED:-true}
public_edge_network=${PRODUCTION_PUBLIC_EDGE_NETWORK:-who_need_help_public_edge}
public_upstream_name=${PRODUCTION_PUBLIC_UPSTREAM_NAME:-who-need-help-production}
case "$compose_project_name" in
*[!a-zA-Z0-9_-]* | '')
@ -79,6 +82,11 @@ case "$app_topology" in
*) echo "PRODUCTION_APP_TOPOLOGY must be compact or split." >&2; exit 1 ;;
esac
case "$public_edge_enabled" in
true | false) ;;
*) echo "PRODUCTION_PUBLIC_EDGE_ENABLED must be true or false." >&2; exit 1 ;;
esac
secret_key_base=$(openssl rand -hex 64)
handover_secret=$(openssl rand -hex 64)
release_cookie=$(openssl rand -hex 64)
@ -108,6 +116,9 @@ APP_TOPOLOGY_VALUE=$app_topology \
COMPOSE_PROJECT_NAME_VALUE=$compose_project_name \
HTTP_BIND_ADDRESS_VALUE=$http_bind_address \
HTTP_PORT_VALUE=$http_port \
PUBLIC_EDGE_ENABLED_VALUE=$public_edge_enabled \
PUBLIC_EDGE_NETWORK_VALUE=$public_edge_network \
PUBLIC_UPSTREAM_NAME_VALUE=$public_upstream_name \
DOCKER_SOCKET_GID_VALUE=$docker_socket_gid \
TRUSTED_PROXY_IPS_VALUE=$trusted_proxy_ips \
POSTGRES_PASSWORD_VALUE=$postgres_password \
@ -134,6 +145,9 @@ SUPPORT_INBOX_ADDRESS_VALUE=$support_inbox_address \
replacement["DATABASE_MODE"] = ENVIRON["DATABASE_MODE_VALUE"]
replacement["HTTP_BIND_ADDRESS"] = ENVIRON["HTTP_BIND_ADDRESS_VALUE"]
replacement["HTTP_PORT"] = ENVIRON["HTTP_PORT_VALUE"]
replacement["PUBLIC_EDGE_ENABLED"] = ENVIRON["PUBLIC_EDGE_ENABLED_VALUE"]
replacement["PUBLIC_EDGE_NETWORK"] = ENVIRON["PUBLIC_EDGE_NETWORK_VALUE"]
replacement["PUBLIC_UPSTREAM_NAME"] = ENVIRON["PUBLIC_UPSTREAM_NAME_VALUE"]
replacement["DOCKER_SOCKET_GID"] = ENVIRON["DOCKER_SOCKET_GID_VALUE"]
replacement["TRAEFIK_TRUSTED_IPS"] = ENVIRON["TRUSTED_PROXY_IPS_VALUE"]
replacement["TRAEFIK_PROJECT_CONSTRAINT"] = ENVIRON["COMPOSE_PROJECT_NAME_VALUE"]

View File

@ -24,6 +24,7 @@ mc_image="who-need-help:mc-audit-$run_id"
boundary_mock_image="who-need-help:boundary-mock-audit-$run_id"
socket_proxy_image="who-need-help:socket-proxy-audit-$run_id"
postgis_image="who-need-help:postgis-audit-$run_id"
caddy_image="who-need-help:caddy-audit-$run_id"
socket_proxy_container="wnh-socket-proxy-audit-$run_id"
scan_dir=$(mktemp -d "${TMPDIR:-/tmp}/wnh-quality-scan.XXXXXX")
scan_list="${scan_dir}.files"
@ -44,6 +45,7 @@ cleanup() {
docker image rm "$quality_image" "$assets_image" "$e2e_image" "$release_image" \
"$backup_image" "$minio_image" "$mc_image" \
"$boundary_mock_image" "$socket_proxy_image" "$postgis_image" \
"$caddy_image" \
>/dev/null 2>&1 || true
rm -rf "$scan_dir" "$scan_list" "$scan_tar"
}
@ -90,6 +92,14 @@ grep -Fx '/act_runner' .dockerignore >/dev/null
grep -Fx '/act_runner-data/' .dockerignore >/dev/null
echo "Checking production environment initialization and validation"
edge_env="$scan_dir/.env.edge"
./scripts/init-edge-env.sh help.test staging.help.test "$edge_env" >/dev/null
test "$(stat -c '%a' "$edge_env")" = 600
./scripts/validate-edge-env.sh "$edge_env" >/dev/null
if ./scripts/init-edge-env.sh help.test staging.help.test "$edge_env" >/dev/null 2>&1; then
echo "Edge environment initializer overwrote an existing file." >&2
exit 1
fi
production_env="$scan_dir/.env.production"
PRODUCTION_TRAEFIK_TRUSTED_IPS=172.20.0.1/32 \
PRODUCTION_SMTP_RELAY=smtp.help.test \
@ -164,6 +174,22 @@ if ./scripts/validate-production-env.sh \
fi
echo "Rendering every Docker Compose profile"
CADDY_IMAGE=who-need-help:caddy-local \
docker compose --project-directory "$ROOT" --env-file "$edge_env" \
--file compose.edge.yaml config --format json |
jq --exit-status '
.services.edge.image == "who-need-help:caddy-local" and
.services.edge.user == "1000:1000" and
.services.edge.read_only == true and
.services.edge.cap_drop == ["ALL"] and
.services.edge.cap_add == ["NET_BIND_SERVICE"] and
.services.edge.security_opt == ["no-new-privileges:true"] and
(.services.edge.tmpfs | index("/tmp") != null) and
(.services.edge.ports | map(select(.target == 80 and .published == "80" and .protocol == "tcp")) | length) == 1 and
(.services.edge.ports | map(select(.target == 443 and .published == "443" and .protocol == "tcp")) | length) == 1 and
(.services.edge.ports | map(select(.target == 443 and .published == "443" and .protocol == "udp")) | length) == 1 and
.networks.public_edge.name == "who_need_help_public_edge"
' >/dev/null
./scripts/compose.sh .env.example config --quiet
./scripts/compose.sh "$production_env" config --quiet
./scripts/compose.sh "$external_production_env" config --quiet
@ -219,6 +245,8 @@ echo "Rendering every Docker Compose profile"
.services.app.environment.APP_ROLE == "combined" and
.services.app.environment.DNS_CLUSTER_QUERY == "ignore" and
.services.app.environment.POOL_SIZE == "4" and
.services.app.networks.public_edge.aliases == ["who-need-help-production"] and
.networks.public_edge.external == true and
.services.app.ports[0].host_ip == "127.0.0.1"
' >/dev/null
./scripts/compose.sh "$external_production_env" config --format json |
@ -227,7 +255,8 @@ echo "Rendering every Docker Compose profile"
(.services | has("db") | not) and
(.services | has("web") | not) and
(.services | has("worker") | not) and
(.services | has("proxy") | not)
(.services | has("proxy") | not) and
.services.app.networks.public_edge.aliases == ["who-need-help-production"]
' >/dev/null
./scripts/compose.sh "$external_split_production_env" config --format json |
jq --exit-status '
@ -236,6 +265,7 @@ echo "Rendering every Docker Compose profile"
(.services | has("web")) and
(.services | has("worker")) and
(.services | has("proxy")) and
.services.web.networks.public_edge.aliases == ["who-need-help-production"] and
.services.web.deploy.replicas == 2 and
.services.worker.deploy.replicas == 2
' >/dev/null
@ -383,8 +413,11 @@ docker run --rm \
echo "Building, smoke-testing, and scanning pinned runtime infrastructure images"
docker build --tag "$socket_proxy_image" --file Dockerfile.socket-proxy .
docker build --tag "$postgis_image" --file Dockerfile.postgis .
docker build --tag "$caddy_image" --file Dockerfile.caddy .
test "$(docker image inspect --format '{{.Config.User}}' "$socket_proxy_image")" = "haproxy"
test "$(docker image inspect --format '{{.Config.User}}' "$postgis_image")" = "postgres"
test "$(docker image inspect --format '{{.Config.User}}' "$caddy_image")" = "1000:1000"
docker run --rm "$caddy_image" version | grep -F 'v2.11.4' >/dev/null
docker run --rm --entrypoint sh "$postgis_image" -euc '
test ! -e /usr/local/bin/gosu
test "$(id -u)" = 70
@ -439,6 +472,7 @@ for image in \
"$socket_proxy_image" \
"$postgis_image" \
"traefik:v3.7.8@sha256:4299bbed850421258fc5448c2e0e6ad350981d4d335a68de11b92448aedbefe5" \
"$caddy_image" \
"axllent/mailpit:v1.30.4@sha256:5a49a77c5bdbe7c5474450b4f46348d09949df3695257729c93a30369382d4f6"; do
docker run --rm \
--volume /var/run/docker.sock:/var/run/docker.sock \

121
scripts/validate-edge-env.sh Executable file
View File

@ -0,0 +1,121 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
env_file=${1:-}
if [[ -z "$env_file" || ! -f "$env_file" ]]; then
echo "Usage: $0 EDGE_ENV_FILE" >&2
exit 1
fi
if [[ "$(stat -c '%a' "$env_file")" != 600 ]]; then
echo "Edge environment must have mode 0600: $env_file" >&2
exit 1
fi
if [[ "$(stat -c '%u' "$env_file")" != "$(id -u)" ]]; then
echo "Edge environment must be owned by the current operator." >&2
exit 1
fi
read_value() {
local key=$1
awk -v key="$key" '
index($0, key "=") == 1 {
print substr($0, length(key) + 2)
found = 1
exit
}
END { if (!found) exit 1 }
' "$env_file"
}
require_value() {
local key=$1
local value
if ! value=$(read_value "$key") || [[ -z "$value" ]]; then
echo "$key is missing or empty in $env_file." >&2
exit 1
fi
printf '%s' "$value"
}
validate_hostname() {
local key=$1
local value=$2
if [[ ! "$value" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$ ]]; then
echo "$key must be a lowercase ASCII DNS hostname." >&2
exit 1
fi
}
validate_upstream() {
local key=$1
local value=$2
if [[ ! "$value" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?:[0-9]+$ ]]; then
echo "$key must be a lowercase host and numeric port." >&2
exit 1
fi
}
compose_project_name=$(require_value COMPOSE_PROJECT_NAME)
public_edge_network=$(require_value PUBLIC_EDGE_NETWORK)
edge_bind_address=$(require_value EDGE_BIND_ADDRESS)
edge_http_port=$(require_value EDGE_HTTP_PORT)
edge_https_port=$(require_value EDGE_HTTPS_PORT)
primary_domain=$(require_value PRIMARY_DOMAIN)
primary_upstream=$(require_value PRIMARY_UPSTREAM)
staging_domain=$(require_value STAGING_DOMAIN)
staging_upstream=$(require_value STAGING_UPSTREAM)
[[ "$compose_project_name" =~ ^[a-zA-Z0-9_-]+$ ]] || {
echo "COMPOSE_PROJECT_NAME contains unsupported characters." >&2
exit 1
}
[[ "$public_edge_network" =~ ^[a-zA-Z0-9_-]+$ ]] || {
echo "PUBLIC_EDGE_NETWORK contains unsupported characters." >&2
exit 1
}
[[ "$edge_bind_address" == "0.0.0.0" || "$edge_bind_address" == "127.0.0.1" ]] || {
echo "EDGE_BIND_ADDRESS must be 0.0.0.0 or 127.0.0.1." >&2
exit 1
}
for port_name in edge_http_port edge_https_port; do
port=${!port_name}
if [[ ! "$port" =~ ^[0-9]+$ ]] || ((port < 1 || port > 65535)); then
echo "${port_name^^} must be between 1 and 65535." >&2
exit 1
fi
done
[[ "$edge_http_port" != "$edge_https_port" ]] || {
echo "EDGE_HTTP_PORT and EDGE_HTTPS_PORT must be different." >&2
exit 1
}
validate_hostname PRIMARY_DOMAIN "$primary_domain"
validate_hostname STAGING_DOMAIN "$staging_domain"
validate_upstream PRIMARY_UPSTREAM "$primary_upstream"
validate_upstream STAGING_UPSTREAM "$staging_upstream"
[[ "$primary_domain" != "$staging_domain" ]] || {
echo "PRIMARY_DOMAIN and STAGING_DOMAIN must be different." >&2
exit 1
}
[[ "$primary_upstream" != "$staging_upstream" ]] || {
echo "PRIMARY_UPSTREAM and STAGING_UPSTREAM must be different." >&2
exit 1
}
docker compose \
--project-directory "$ROOT" \
--env-file "$env_file" \
--file "$ROOT/compose.edge.yaml" \
config --quiet
echo "Public edge environment passed structural validation."

View File

@ -82,6 +82,9 @@ phx_url_port=$(require_value PHX_URL_PORT)
base_url=$(require_value WNH_BASE_URL)
debug_base_url=$(require_value WNH_DEBUG_BASE_URL)
http_bind_address=$(require_value HTTP_BIND_ADDRESS)
public_edge_enabled=$(require_value PUBLIC_EDGE_ENABLED)
public_edge_network=$(require_value PUBLIC_EDGE_NETWORK)
public_upstream_name=$(require_value PUBLIC_UPSTREAM_NAME)
trusted_proxy_ips=$(optional_value TRAEFIK_TRUSTED_IPS)
postgres_password=$(optional_value POSTGRES_PASSWORD)
database_url=$(require_value DATABASE_URL)
@ -121,6 +124,20 @@ google_oauth_client_secret=$(optional_value GOOGLE_OAUTH_CLIENT_SECRET)
echo "DATABASE_MODE must be container or external." >&2
exit 1
}
[[ "$public_edge_enabled" =~ ^(true|false)$ ]] || {
echo "PUBLIC_EDGE_ENABLED must be true or false." >&2
exit 1
}
if [[ "$public_edge_enabled" == true ]]; then
[[ "$public_edge_network" =~ ^[a-zA-Z0-9_-]+$ ]] || {
echo "PUBLIC_EDGE_NETWORK contains unsupported characters." >&2
exit 1
}
[[ "$public_upstream_name" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || {
echo "PUBLIC_UPSTREAM_NAME must be a lowercase Docker DNS alias." >&2
exit 1
}
fi
[[ "$phx_host" == "$expected_domain" ]] || {
echo "PHX_HOST does not match EXPECTED_DOMAIN." >&2