feat: prepare first production deployment

This commit is contained in:
SimpleTest 2026-07-20 21:41:33 +03:00
parent 9b51d0807b
commit 14987bb21b
10 changed files with 441 additions and 1 deletions

View File

@ -1,6 +1,10 @@
# Copy this file to .env. Compose intentionally refuses to start without the # Copy this file to .env. Compose intentionally refuses to start without the
# required values. Replace every credential before any public deployment. # required values. Replace every credential before any public deployment.
HTTP_PORT=4010 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_PORT=8027
MAILPIT_BIND_ADDRESS=127.0.0.1 MAILPIT_BIND_ADDRESS=127.0.0.1
DOCKER_SOCKET_GID=REPLACE_WITH_DOCKER_SOCKET_NUMERIC_GID DOCKER_SOCKET_GID=REPLACE_WITH_DOCKER_SOCKET_NUMERIC_GID

View File

@ -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 links, and must set `app.mapTileUrl` to a tile service whose policy and capacity
fit the deployment. 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 Rotate all local application secrets and the existing local PostgreSQL role
without printing the generated values: without printing the generated values:

6
compose.production.yaml Normal file
View File

@ -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]

View File

@ -87,7 +87,7 @@ services:
- --entrypoints.websecure.address=:443 - --entrypoints.websecure.address=:443
- --entrypoints.web.forwardedheaders.trustedips=${TRAEFIK_TRUSTED_IPS:-127.0.0.1/32} - --entrypoints.web.forwardedheaders.trustedips=${TRAEFIK_TRUSTED_IPS:-127.0.0.1/32}
ports: ports:
- "${HTTP_PORT:-4010}:80" - "${HTTP_BIND_ADDRESS:-0.0.0.0}:${HTTP_PORT:-4010}:80"
depends_on: depends_on:
- docker-api-proxy - docker-api-proxy
networks: [docker-api, edge, ingress] networks: [docker-api, edge, ingress]

View File

@ -6,6 +6,53 @@ time objective, retention period, storage capacity, or high-availability model;
those values require product policy and measurements from the eventual those values require product policy and measurements from the eventual
production environment. 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 ## Compose database backup
Create a PostgreSQL 18 custom-format archive, validate its table of contents, Create a PostgreSQL 18 custom-format archive, validate its table of contents,

View File

@ -27,6 +27,17 @@
<.form :let={f} for={@password_changeset} action={~p"/users/settings"} id="update_password"> <.form :let={f} for={@password_changeset} action={~p"/users/settings"} id="update_password">
<input type="hidden" name="action" value="update_password" /> <input type="hidden" name="action" value="update_password" />
<input
type="email"
id="update_password_username"
name="username"
value={@current_scope.user.email}
autocomplete="username"
class="hidden"
aria-hidden="true"
tabindex="-1"
readonly
/>
<.input <.input
field={f[:password]} field={f[:password]}

133
scripts/init-production-env.sh Executable file
View File

@ -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."

View File

@ -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
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" echo "Rendering every Docker Compose profile"
docker compose --env-file .env.example -f compose.yaml config --quiet 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 | docker compose --env-file .env.example -f compose.yaml config --format json |
jq --exit-status ' jq --exit-status '
. as $root . 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.internal.internal == true
and ($root.networks.egress.internal // false) == false and ($root.networks.egress.internal // false) == false
and $root.services.db.security_opt == ["no-new-privileges:true"] 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" and $root.services.mailpit.ports[0].host_ip == "127.0.0.1"
' >/dev/null ' >/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 \ WEB_REPLICAS=1 WORKER_REPLICAS=1 \
docker compose --env-file .env.example -f compose.yaml config --format json | docker compose --env-file .env.example -f compose.yaml config --format json |
jq --exit-status ' jq --exit-status '

View File

@ -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."

View File

@ -12,6 +12,8 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
conn = get(conn, ~p"/users/settings") conn = get(conn, ~p"/users/settings")
response = html_response(conn, 200) response = html_response(conn, 200)
assert response =~ "Settings" assert response =~ "Settings"
assert response =~ ~s(id="update_password_username")
assert response =~ ~s(autocomplete="username")
end end
test "redirects if user is not logged in" do test "redirects if user is not logged in" do