diff --git a/.env.example b/.env.example
index a2ea219..8e2df3b 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,10 @@
# Copy this file to .env. Compose intentionally refuses to start without the
# required values. Replace every credential before any public deployment.
HTTP_PORT=4010
+# Bind the public Compose proxy to loopback when a reverse proxy runs on the
+# 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
MAILPIT_PORT=8027
MAILPIT_BIND_ADDRESS=127.0.0.1
DOCKER_SOCKET_GID=REPLACE_WITH_DOCKER_SOCKET_NUMERIC_GID
diff --git a/README.md b/README.md
index 9a78a3a..d52e52c 100644
--- a/README.md
+++ b/README.md
@@ -168,6 +168,34 @@ set `app.host`, `app.scheme`, and `app.urlPort` to the public URL used in email
links, and must set `app.mapTileUrl` to a tile service whose policy and capacity
fit the deployment.
+For a first Compose deployment, generate an ignored environment on the target
+Docker host. The command derives that host's Docker socket group, generates
+independent database, Phoenix, handover, cluster, and metrics secrets without
+printing them, writes mode `0600`, and refuses to replace an existing file:
+
+```bash
+./scripts/init-production-env.sh whoneedhelp.com .env.production
+```
+
+Configure the verified reverse-proxy source IP/CIDR and transactional SMTP
+provider in that file, then validate its structure and the production Compose
+render:
+
+```bash
+./scripts/validate-production-env.sh .env.production whoneedhelp.com
+docker compose \
+ --env-file .env.production \
+ -f compose.yaml \
+ -f compose.production.yaml \
+ up -d --wait --build
+```
+
+`compose.production.yaml` leaves local Mailpit stopped. Validation deliberately
+fails while the relay still points to Mailpit or a template marker remains.
+It does not claim to test DNS, certificates, actual mail delivery, the
+deployment's observed proxy source address, or capacity; verify those on the
+target host before opening registration.
+
Rotate all local application secrets and the existing local PostgreSQL role
without printing the generated values:
diff --git a/compose.production.yaml b/compose.production.yaml
new file mode 100644
index 0000000..6d3dce4
--- /dev/null
+++ b/compose.production.yaml
@@ -0,0 +1,6 @@
+services:
+ # Public deployments use the configured transactional SMTP relay. Mailpit is
+ # retained in the base file for local development but is not started by this
+ # production override unless the operator explicitly enables local-mail.
+ mailpit:
+ profiles: [local-mail]
diff --git a/compose.yaml b/compose.yaml
index 97854bf..794b962 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -87,7 +87,7 @@ services:
- --entrypoints.websecure.address=:443
- --entrypoints.web.forwardedheaders.trustedips=${TRAEFIK_TRUSTED_IPS:-127.0.0.1/32}
ports:
- - "${HTTP_PORT:-4010}:80"
+ - "${HTTP_BIND_ADDRESS:-0.0.0.0}:${HTTP_PORT:-4010}:80"
depends_on:
- docker-api-proxy
networks: [docker-api, edge, ingress]
diff --git a/docs/operations.md b/docs/operations.md
index eaa71fd..b39c3f9 100644
--- a/docs/operations.md
+++ b/docs/operations.md
@@ -6,6 +6,53 @@ time objective, retention period, storage capacity, or high-availability model;
those values require product policy and measurements from the eventual
production environment.
+## First production Compose environment
+
+Run the initializer on the target Docker host after its final public hostname
+is known:
+
+```bash
+./scripts/init-production-env.sh whoneedhelp.com .env.production
+```
+
+The initializer reads `.env.example`, derives the numeric group of that host's
+Docker socket, generates independent random values for PostgreSQL,
+`SECRET_KEY_BASE`, handover codes, the BEAM release cookie, and metrics access,
+and writes an ignored mode-`0600` file. It does not print those values and
+refuses to overwrite an existing destination.
+
+By default, the generated public proxy port binds to `127.0.0.1`, which is
+appropriate only when the verified reverse proxy reaches the application on
+the same host. Set `PRODUCTION_HTTP_BIND_ADDRESS` when generating the file, or
+edit `HTTP_BIND_ADDRESS` afterward, to match the observed target topology.
+Replace `TRAEFIK_TRUSTED_IPS` with the exact source IP/CIDR observed at Traefik;
+do not copy the temporary VPN value into an unrelated server.
+
+Configure the transactional SMTP relay and sender accepted by that provider.
+Provider-specific auth, TLS, ports, and credentials can be supplied to the
+initializer through the documented `PRODUCTION_SMTP_*` environment values or
+edited in the resulting ignored file. Then run:
+
+```bash
+./scripts/validate-production-env.sh .env.production whoneedhelp.com
+docker compose \
+ --env-file .env.production \
+ -f compose.yaml \
+ -f compose.production.yaml \
+ up -d --wait --build
+```
+
+The production override keeps Mailpit behind its inactive `local-mail` profile,
+so public registration cannot appear to succeed while mail is only retained
+locally. The validator checks file ownership/mode, origin consistency,
+template markers, independent generated secrets, supported SMTP values, the
+generated database URL, and the final Compose render without printing secrets.
+It does not contact DNS, TLS, SMTP, the reverse proxy, or the application.
+After deployment, verify `/healthz/ready`, inspect all replica health and logs,
+register a unique address through the public browser, receive its message at
+the real mailbox, follow the HTTPS confirmation link, and remove only that
+run-scoped account.
+
## Compose database backup
Create a PostgreSQL 18 custom-format archive, validate its table of contents,
diff --git a/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex b/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex
index 7193e9f..d2efc09 100644
--- a/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex
+++ b/lib/who_need_help_web/controllers/user_settings_html/edit.html.heex
@@ -27,6 +27,17 @@
<.form :let={f} for={@password_changeset} action={~p"/users/settings"} id="update_password">
+
<.input
field={f[:password]}
diff --git a/scripts/init-production-env.sh b/scripts/init-production-env.sh
new file mode 100755
index 0000000..917b054
--- /dev/null
+++ b/scripts/init-production-env.sh
@@ -0,0 +1,133 @@
+#!/bin/sh
+set -eu
+umask 077
+
+ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
+domain=${1:-}
+target=${2:-"$ROOT/.env.production"}
+
+usage() {
+ echo "Usage: $0 DOMAIN [OUTPUT_FILE]" >&2
+}
+
+if [ -z "$domain" ]; then
+ usage
+ exit 1
+fi
+
+if ! printf '%s\n' "$domain" |
+ grep -Eq '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$'; then
+ echo "DOMAIN must be a lowercase ASCII DNS hostname without a scheme, port, or path." >&2
+ exit 1
+fi
+
+for command in awk docker grep mktemp openssl stat; do
+ if ! command -v "$command" >/dev/null 2>&1; then
+ echo "Required command is unavailable: $command" >&2
+ exit 1
+ fi
+done
+
+if [ ! -S /var/run/docker.sock ]; then
+ echo "/var/run/docker.sock is unavailable; run this on the target Docker host." >&2
+ exit 1
+fi
+
+if [ -e "$target" ]; then
+ echo "Refusing to overwrite existing deployment 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
+
+postgres_password=$(openssl rand -hex 32)
+secret_key_base=$(openssl rand -hex 64)
+handover_secret=$(openssl rand -hex 64)
+release_cookie=$(openssl rand -hex 64)
+metrics_token=$(openssl rand -hex 32)
+docker_socket_gid=$(stat -c '%g' /var/run/docker.sock)
+
+http_bind_address=${PRODUCTION_HTTP_BIND_ADDRESS:-127.0.0.1}
+trusted_proxy_ips=${PRODUCTION_TRAEFIK_TRUSTED_IPS:-REPLACE_WITH_VERIFIED_PROXY_IP_OR_CIDR}
+smtp_relay=${PRODUCTION_SMTP_RELAY:-REPLACE_WITH_TRANSACTIONAL_SMTP_RELAY}
+smtp_port=${PRODUCTION_SMTP_PORT:-587}
+smtp_username=${PRODUCTION_SMTP_USERNAME:-}
+smtp_password=${PRODUCTION_SMTP_PASSWORD:-}
+smtp_auth=${PRODUCTION_SMTP_AUTH:-always}
+smtp_tls=${PRODUCTION_SMTP_TLS:-always}
+smtp_ssl=${PRODUCTION_SMTP_SSL:-false}
+email_from_address=${PRODUCTION_EMAIL_FROM_ADDRESS:-"contact@$domain"}
+
+tmp=$(mktemp "$target_dir/.production-env.XXXXXX")
+trap 'rm -f "$tmp"' EXIT HUP INT TERM
+chmod 600 "$tmp"
+
+DOMAIN=$domain \
+HTTP_BIND_ADDRESS_VALUE=$http_bind_address \
+DOCKER_SOCKET_GID_VALUE=$docker_socket_gid \
+TRUSTED_PROXY_IPS_VALUE=$trusted_proxy_ips \
+POSTGRES_PASSWORD_VALUE=$postgres_password \
+DATABASE_URL_VALUE="ecto://postgres:$postgres_password@db/who_need_help" \
+SECRET_KEY_BASE_VALUE=$secret_key_base \
+HANDOVER_SECRET_VALUE=$handover_secret \
+RELEASE_COOKIE_VALUE=$release_cookie \
+METRICS_TOKEN_VALUE=$metrics_token \
+SMTP_RELAY_VALUE=$smtp_relay \
+SMTP_PORT_VALUE=$smtp_port \
+SMTP_USERNAME_VALUE=$smtp_username \
+SMTP_PASSWORD_VALUE=$smtp_password \
+SMTP_AUTH_VALUE=$smtp_auth \
+SMTP_TLS_VALUE=$smtp_tls \
+SMTP_SSL_VALUE=$smtp_ssl \
+EMAIL_FROM_ADDRESS_VALUE=$email_from_address \
+ awk '
+ BEGIN {
+ replacement["HTTP_BIND_ADDRESS"] = ENVIRON["HTTP_BIND_ADDRESS_VALUE"]
+ replacement["DOCKER_SOCKET_GID"] = ENVIRON["DOCKER_SOCKET_GID_VALUE"]
+ replacement["TRAEFIK_TRUSTED_IPS"] = ENVIRON["TRUSTED_PROXY_IPS_VALUE"]
+ replacement["PHX_HOST"] = ENVIRON["DOMAIN"]
+ replacement["PHX_SCHEME"] = "https"
+ replacement["PHX_URL_PORT"] = "443"
+ replacement["WNH_DEBUG_BASE_URL"] = "https://" ENVIRON["DOMAIN"]
+ replacement["WNH_BASE_URL"] = "https://" ENVIRON["DOMAIN"]
+ replacement["POSTGRES_PASSWORD"] = ENVIRON["POSTGRES_PASSWORD_VALUE"]
+ replacement["DATABASE_URL"] = ENVIRON["DATABASE_URL_VALUE"]
+ replacement["SECRET_KEY_BASE"] = ENVIRON["SECRET_KEY_BASE_VALUE"]
+ replacement["HANDOVER_SECRET"] = ENVIRON["HANDOVER_SECRET_VALUE"]
+ replacement["RELEASE_COOKIE"] = ENVIRON["RELEASE_COOKIE_VALUE"]
+ replacement["METRICS_TOKEN"] = ENVIRON["METRICS_TOKEN_VALUE"]
+ replacement["SMTP_RELAY"] = ENVIRON["SMTP_RELAY_VALUE"]
+ replacement["SMTP_PORT"] = ENVIRON["SMTP_PORT_VALUE"]
+ replacement["SMTP_USERNAME"] = ENVIRON["SMTP_USERNAME_VALUE"]
+ replacement["SMTP_PASSWORD"] = ENVIRON["SMTP_PASSWORD_VALUE"]
+ replacement["SMTP_AUTH"] = ENVIRON["SMTP_AUTH_VALUE"]
+ replacement["SMTP_TLS"] = ENVIRON["SMTP_TLS_VALUE"]
+ replacement["SMTP_SSL"] = ENVIRON["SMTP_SSL_VALUE"]
+ replacement["EMAIL_FROM_ADDRESS"] = ENVIRON["EMAIL_FROM_ADDRESS_VALUE"]
+ replacement["CODEX_SESSION_ID"] = "not-configured"
+ }
+ {
+ separator = index($0, "=")
+ key = separator > 1 ? substr($0, 1, separator - 1) : ""
+
+ if (key in replacement) {
+ print key "=" replacement[key]
+ } else {
+ print
+ }
+ }
+ ' "$ROOT/.env.example" >"$tmp"
+
+mv "$tmp" "$target"
+chmod 600 "$target"
+trap - EXIT HUP INT TERM
+
+unset postgres_password secret_key_base handover_secret release_cookie metrics_token
+
+echo "Generated independent deployment secrets without printing them."
+echo "Created mode-0600 environment: $target"
+echo "Run scripts/validate-production-env.sh '$target' '$domain' after configuring the verified proxy and SMTP values."
diff --git a/scripts/quality.sh b/scripts/quality.sh
index efa6c86..3bb33eb 100755
--- a/scripts/quality.sh
+++ b/scripts/quality.sh
@@ -78,8 +78,36 @@ echo "Checking crash dumps are excluded from the Docker build context"
grep -Fx 'core' .dockerignore >/dev/null
grep -Fx 'core.*' .dockerignore >/dev/null
+echo "Checking production environment initialization and validation"
+production_env="$scan_dir/.env.production"
+PRODUCTION_TRAEFIK_TRUSTED_IPS=172.20.0.1/32 \
+PRODUCTION_SMTP_RELAY=smtp.help.test \
+PRODUCTION_SMTP_PORT=587 \
+PRODUCTION_SMTP_USERNAME=quality-user \
+PRODUCTION_SMTP_PASSWORD=quality-password \
+PRODUCTION_SMTP_AUTH=always \
+PRODUCTION_SMTP_TLS=always \
+PRODUCTION_SMTP_SSL=false \
+PRODUCTION_EMAIL_FROM_ADDRESS=contact@help.test \
+ ./scripts/init-production-env.sh help.test "$production_env" >/dev/null
+test "$(stat -c '%a' "$production_env")" = 600
+./scripts/validate-production-env.sh "$production_env" help.test >/dev/null
+if ./scripts/init-production-env.sh help.test "$production_env" >/dev/null 2>&1; then
+ echo "Production environment initializer overwrote an existing file." >&2
+ exit 1
+fi
+incomplete_production_env="$scan_dir/.env.production.incomplete"
+./scripts/init-production-env.sh help.test "$incomplete_production_env" >/dev/null
+if ./scripts/validate-production-env.sh \
+ "$incomplete_production_env" help.test >/dev/null 2>&1; then
+ echo "Production environment validator accepted unresolved deployment inputs." >&2
+ exit 1
+fi
+
echo "Rendering every Docker Compose profile"
docker compose --env-file .env.example -f compose.yaml config --quiet
+docker compose --env-file "$production_env" \
+ -f compose.yaml -f compose.production.yaml config --quiet
docker compose --env-file .env.example -f compose.yaml config --format json |
jq --exit-status '
. as $root
@@ -115,8 +143,14 @@ docker compose --env-file .env.example -f compose.yaml config --format json |
and $root.networks.internal.internal == true
and ($root.networks.egress.internal // false) == false
and $root.services.db.security_opt == ["no-new-privileges:true"]
+ and $root.services.proxy.ports[0].host_ip == "0.0.0.0"
and $root.services.mailpit.ports[0].host_ip == "127.0.0.1"
' >/dev/null
+HTTP_BIND_ADDRESS=127.0.0.1 \
+ docker compose --env-file .env.example -f compose.yaml config --format json |
+ jq --exit-status '
+ .services.proxy.ports[0].host_ip == "127.0.0.1"
+ ' >/dev/null
WEB_REPLICAS=1 WORKER_REPLICAS=1 \
docker compose --env-file .env.example -f compose.yaml config --format json |
jq --exit-status '
diff --git a/scripts/validate-production-env.sh b/scripts/validate-production-env.sh
new file mode 100755
index 0000000..11eaec8
--- /dev/null
+++ b/scripts/validate-production-env.sh
@@ -0,0 +1,175 @@
+#!/bin/bash
+set -euo pipefail
+
+ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
+env_file=${1:-}
+expected_domain=${2:-}
+
+usage() {
+ echo "Usage: $0 ENV_FILE EXPECTED_DOMAIN" >&2
+}
+
+if [[ -z "$env_file" || -z "$expected_domain" ]]; then
+ usage
+ exit 1
+fi
+
+if [[ ! -f "$env_file" ]]; then
+ echo "Deployment environment does not exist: $env_file" >&2
+ exit 1
+fi
+
+if [[ "$(stat -c '%a' "$env_file")" != 600 ]]; then
+ echo "Deployment environment must have mode 0600: $env_file" >&2
+ exit 1
+fi
+
+if [[ "$(stat -c '%u' "$env_file")" != "$(id -u)" ]]; then
+ echo "Deployment 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
+ }
+ 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"
+}
+
+reject_marker() {
+ local key=$1
+ local value=$2
+
+ case "$value" in
+ *REPLACE* | *GENERATE* | *example.com*)
+ echo "$key still contains a template value." >&2
+ exit 1
+ ;;
+ esac
+}
+
+phx_host=$(require_value PHX_HOST)
+phx_scheme=$(require_value PHX_SCHEME)
+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)
+trusted_proxy_ips=$(require_value TRAEFIK_TRUSTED_IPS)
+postgres_password=$(require_value POSTGRES_PASSWORD)
+database_url=$(require_value DATABASE_URL)
+secret_key_base=$(require_value SECRET_KEY_BASE)
+handover_secret=$(require_value HANDOVER_SECRET)
+release_cookie=$(require_value RELEASE_COOKIE)
+metrics_token=$(require_value METRICS_TOKEN)
+smtp_relay=$(require_value SMTP_RELAY)
+smtp_port=$(require_value SMTP_PORT)
+smtp_auth=$(require_value SMTP_AUTH)
+smtp_tls=$(require_value SMTP_TLS)
+smtp_ssl=$(require_value SMTP_SSL)
+email_from_address=$(require_value EMAIL_FROM_ADDRESS)
+
+[[ "$phx_host" == "$expected_domain" ]] || {
+ echo "PHX_HOST does not match EXPECTED_DOMAIN." >&2
+ exit 1
+}
+[[ "$phx_scheme" == https && "$phx_url_port" == 443 ]] || {
+ echo "Production PHX_SCHEME/PHX_URL_PORT must describe the public HTTPS origin." >&2
+ exit 1
+}
+[[ "$base_url" == "https://$expected_domain" ]] || {
+ echo "WNH_BASE_URL does not match the public HTTPS origin." >&2
+ exit 1
+}
+[[ "$debug_base_url" == "$base_url" ]] || {
+ echo "WNH_DEBUG_BASE_URL and WNH_BASE_URL must use the same deployment origin." >&2
+ exit 1
+}
+
+for pair in \
+ "HTTP_BIND_ADDRESS:$http_bind_address" \
+ "TRAEFIK_TRUSTED_IPS:$trusted_proxy_ips" \
+ "POSTGRES_PASSWORD:$postgres_password" \
+ "DATABASE_URL:$database_url" \
+ "SECRET_KEY_BASE:$secret_key_base" \
+ "HANDOVER_SECRET:$handover_secret" \
+ "RELEASE_COOKIE:$release_cookie" \
+ "METRICS_TOKEN:$metrics_token" \
+ "SMTP_RELAY:$smtp_relay" \
+ "EMAIL_FROM_ADDRESS:$email_from_address"
+do
+ reject_marker "${pair%%:*}" "${pair#*:}"
+done
+
+[[ "$smtp_relay" != mailpit ]] || {
+ echo "SMTP_RELAY still targets local Mailpit; public registration needs a transactional relay." >&2
+ exit 1
+}
+[[ "$smtp_port" =~ ^[0-9]+$ ]] || {
+ echo "SMTP_PORT must be numeric." >&2
+ exit 1
+}
+[[ "$smtp_auth" =~ ^(always|never|if_available)$ ]] || {
+ echo "SMTP_AUTH has an unsupported value." >&2
+ exit 1
+}
+[[ "$smtp_tls" =~ ^(always|never|if_available)$ ]] || {
+ echo "SMTP_TLS has an unsupported value." >&2
+ exit 1
+}
+[[ "$smtp_ssl" =~ ^(true|false|0|1)$ ]] || {
+ echo "SMTP_SSL has an unsupported value." >&2
+ exit 1
+}
+[[ "$email_from_address" == *@* ]] || {
+ echo "EMAIL_FROM_ADDRESS is not an email address." >&2
+ exit 1
+}
+
+secrets=(
+ "$postgres_password"
+ "$secret_key_base"
+ "$handover_secret"
+ "$release_cookie"
+ "$metrics_token"
+)
+
+for ((left = 0; left < ${#secrets[@]}; left++)); do
+ for ((right = left + 1; right < ${#secrets[@]}; right++)); do
+ if [[ "${secrets[$left]}" == "${secrets[$right]}" ]]; then
+ echo "Deployment secrets must be independent." >&2
+ exit 1
+ fi
+ done
+done
+
+expected_database_url="ecto://postgres:$postgres_password@db/who_need_help"
+[[ "$database_url" == "$expected_database_url" ]] || {
+ echo "DATABASE_URL does not match the generated PostgreSQL role/password/database." >&2
+ exit 1
+}
+
+docker compose \
+ --env-file "$env_file" \
+ --file "$ROOT/compose.yaml" \
+ --file "$ROOT/compose.production.yaml" \
+ config --quiet
+
+echo "Production environment structure passed validation without printing secrets."
+echo "This does not test DNS, TLS, SMTP reachability/delivery, proxy source IPs, or server capacity."
diff --git a/test/who_need_help_web/controllers/user_settings_controller_test.exs b/test/who_need_help_web/controllers/user_settings_controller_test.exs
index 1a380b7..4a9959b 100644
--- a/test/who_need_help_web/controllers/user_settings_controller_test.exs
+++ b/test/who_need_help_web/controllers/user_settings_controller_test.exs
@@ -12,6 +12,8 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
conn = get(conn, ~p"/users/settings")
response = html_response(conn, 200)
assert response =~ "Settings"
+ assert response =~ ~s(id="update_password_username")
+ assert response =~ ~s(autocomplete="username")
end
test "redirects if user is not logged in" do