Merge pull request 'Add file_envs for secrets and allow passing database url via separate envs' (#246) from add-file-envs into main
Some checks reported errors
continuous-integration/drone/push Build was killed

Reviewed-on: #246
Reviewed-by: rafael <rafael@noreply.git.local-it.org>
This commit is contained in:
simon 2025-12-03 14:29:32 +01:00
commit a10d42f1ed
6 changed files with 172 additions and 36 deletions

3
.gitignore vendored
View file

@ -41,3 +41,6 @@ npm-debug.log
.env .env
.elixir_ls/ .elixir_ls/
# Docker secrets directory (generated by `just init-secrets`)
/secrets/

View file

@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- CopyToClipboard JavaScript hook with fallback for older browsers - CopyToClipboard JavaScript hook with fallback for older browsers
- Button shows count of visible selected members (respects search/filter) - Button shows count of visible selected members (respects search/filter)
- German/English translations - German/English translations
- Docker secrets support via `_FILE` environment variables for all sensitive configuration (SECRET_KEY_BASE, TOKEN_SIGNING_SECRET, OIDC_CLIENT_SECRET, DATABASE_URL, DATABASE_PASSWORD)
### Fixed ### Fixed
- Email validation false positive when linking user and member with identical emails (#168 Problem #4) - Email validation false positive when linking user and member with identical emails (#168 Problem #4)

View file

@ -91,3 +91,26 @@ remove-gettext-conflicts:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
find priv/gettext -type f -exec sed -i '/^<<<<<<< HEAD$/d; /^=======$/d; /^>>>>>>>/d' {} \; find priv/gettext -type f -exec sed -i '/^<<<<<<< HEAD$/d; /^=======$/d; /^>>>>>>>/d' {} \;
# Production environment commands
# ================================
# Initialize secrets directory with generated secrets (only if not exists)
init-prod-secrets:
#!/usr/bin/env bash
set -euo pipefail
if [ -d "secrets" ]; then
echo "Secrets directory already exists. Skipping generation."
exit 0
fi
echo "Creating secrets directory and generating secrets..."
mkdir -p secrets
mix phx.gen.secret > secrets/secret_key_base.txt
mix phx.gen.secret > secrets/token_signing_secret.txt
openssl rand -base64 32 | tr -d '\n' > secrets/db_password.txt
touch secrets/oidc_client_secret.txt
echo "Secrets generated in ./secrets/"
# Start production environment with Docker Compose
start-prod: init-prod-secrets
docker compose -f docker-compose.prod.yml up -d

View file

@ -217,6 +217,13 @@ For testing the production Docker build locally:
# OIDC_BASE_URL=http://localhost:8080/auth/v1 # OIDC_BASE_URL=http://localhost:8080/auth/v1
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback # OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback
# OIDC_CLIENT_SECRET=<from-rauthy-client> # OIDC_CLIENT_SECRET=<from-rauthy-client>
# Alternative: Use _FILE variables for Docker secrets (takes priority over regular vars):
# SECRET_KEY_BASE_FILE=/run/secrets/secret_key_base
# TOKEN_SIGNING_SECRET_FILE=/run/secrets/token_signing_secret
# OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret
# DATABASE_URL_FILE=/run/secrets/database_url
# DATABASE_PASSWORD_FILE=/run/secrets/database_password
``` ```
3. **Start development environment** (for Rauthy): 3. **Start development environment** (for Rauthy):
@ -250,7 +257,7 @@ For actual production deployment:
- Set `OIDC_BASE_URL` to your production OIDC provider - Set `OIDC_BASE_URL` to your production OIDC provider
- Configure proper Docker networks - Configure proper Docker networks
3. **Set up SSL/TLS** (e.g., via reverse proxy like Nginx/Traefik) 3. **Set up SSL/TLS** (e.g., via reverse proxy like Nginx/Traefik)
4. **Use secure secrets management** (environment variables, Docker secrets, vault) 4. **Use secure secrets management** — All sensitive environment variables support a `_FILE` suffix for Docker secrets (e.g., `SECRET_KEY_BASE_FILE=/run/secrets/secret_key_base`). See `docker-compose.prod.yml` for an example setup with Docker secrets.
5. **Configure database backups** 5. **Configure database backups**

View file

@ -7,6 +7,75 @@ import Config
# any compile-time configuration in here, as it won't be applied. # any compile-time configuration in here, as it won't be applied.
# The block below contains prod specific runtime configuration. # The block below contains prod specific runtime configuration.
# Helper function to read environment variables with Docker secrets support.
# Supports the _FILE suffix pattern: if VAR_FILE is set, reads the value from
# that file path. Otherwise falls back to VAR directly.
# VAR_FILE takes priority and must contain the full absolute path to the secret file.
get_env_or_file = fn var_name, default ->
file_var = "#{var_name}_FILE"
case System.get_env(file_var) do
nil ->
System.get_env(var_name, default)
file_path ->
case File.read(file_path) do
{:ok, content} ->
String.trim_trailing(content)
{:error, reason} ->
raise """
Failed to read secret from file specified in #{file_var}="#{file_path}".
Error: #{inspect(reason)}
"""
end
end
end
# Same as get_env_or_file but raises if the value is not set
get_env_or_file! = fn var_name, error_message ->
case get_env_or_file.(var_name, nil) do
nil -> raise error_message
value -> value
end
end
# Build database URL from individual components or use DATABASE_URL directly.
# Supports both approaches:
# 1. DATABASE_URL (or DATABASE_URL_FILE) - full connection URL
# 2. Separate vars: DATABASE_HOST, DATABASE_USER, DATABASE_PASSWORD (or _FILE), DATABASE_NAME, DATABASE_PORT
build_database_url = fn ->
case get_env_or_file.("DATABASE_URL", nil) do
nil ->
# Build URL from separate components
host =
System.get_env("DATABASE_HOST") ||
raise "DATABASE_HOST is required when DATABASE_URL is not set"
user =
System.get_env("DATABASE_USER") ||
raise "DATABASE_USER is required when DATABASE_URL is not set"
password =
get_env_or_file!.("DATABASE_PASSWORD", """
DATABASE_PASSWORD or DATABASE_PASSWORD_FILE is required when DATABASE_URL is not set.
""")
database =
System.get_env("DATABASE_NAME") ||
raise "DATABASE_NAME is required when DATABASE_URL is not set"
port = System.get_env("DATABASE_PORT", "5432")
# URL-encode the password to handle special characters
encoded_password = URI.encode_www_form(password)
"ecto://#{user}:#{encoded_password}@#{host}:#{port}/#{database}"
url ->
url
end
end
# ## Using releases # ## Using releases
# #
# If you use `mix release`, you need to explicitly enable the server # If you use `mix release`, you need to explicitly enable the server
@ -21,12 +90,7 @@ if System.get_env("PHX_SERVER") do
end end
if config_env() == :prod do if config_env() == :prod do
database_url = database_url = build_database_url.()
System.get_env("DATABASE_URL") ||
raise """
environment variable DATABASE_URL is missing.
For example: ecto://USER:PASS@HOST/DATABASE
"""
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
@ -41,12 +105,12 @@ if config_env() == :prod do
# want to use a different value for prod and you most likely don't want # want to use a different value for prod and you most likely don't want
# to check this value into version control, so we use an environment # to check this value into version control, so we use an environment
# variable instead. # variable instead.
# Supports SECRET_KEY_BASE or SECRET_KEY_BASE_FILE for Docker secrets.
secret_key_base = secret_key_base =
System.get_env("SECRET_KEY_BASE") || get_env_or_file!.("SECRET_KEY_BASE", """
raise """ environment variable SECRET_KEY_BASE (or SECRET_KEY_BASE_FILE) is missing.
environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret You can generate one by calling: mix phx.gen.secret
""" """)
host = System.get_env("PHX_HOST") || raise "Please define the PHX_HOST environment variable." host = System.get_env("PHX_HOST") || raise "Please define the PHX_HOST environment variable."
port = String.to_integer(System.get_env("PORT") || "4000") port = String.to_integer(System.get_env("PORT") || "4000")
@ -54,32 +118,47 @@ if config_env() == :prod do
config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
# Rauthy OIDC configuration # Rauthy OIDC configuration
# Supports OIDC_CLIENT_SECRET or OIDC_CLIENT_SECRET_FILE for Docker secrets.
# OIDC_CLIENT_SECRET is required only if OIDC is being used (indicated by explicit OIDC env vars).
oidc_base_url = System.get_env("OIDC_BASE_URL")
oidc_client_id = System.get_env("OIDC_CLIENT_ID")
oidc_in_use = not is_nil(oidc_base_url) or not is_nil(oidc_client_id)
client_secret =
if oidc_in_use do
get_env_or_file!.("OIDC_CLIENT_SECRET", """
environment variable OIDC_CLIENT_SECRET (or OIDC_CLIENT_SECRET_FILE) is missing.
This is required when OIDC authentication is configured (OIDC_BASE_URL or OIDC_CLIENT_ID is set).
""")
else
get_env_or_file.("OIDC_CLIENT_SECRET", nil)
end
config :mv, :rauthy, config :mv, :rauthy,
client_id: System.get_env("OIDC_CLIENT_ID") || "mv", client_id: oidc_client_id || "mv",
base_url: System.get_env("OIDC_BASE_URL") || "http://localhost:8080/auth/v1", base_url: oidc_base_url || "http://localhost:8080/auth/v1",
client_secret: System.get_env("OIDC_CLIENT_SECRET"), client_secret: client_secret,
redirect_uri: redirect_uri:
System.get_env("OIDC_REDIRECT_URI") || "http://#{host}:#{port}/auth/user/rauthy/callback" System.get_env("OIDC_REDIRECT_URI") || "http://#{host}:#{port}/auth/user/rauthy/callback"
# Token signing secret from environment variable # Token signing secret from environment variable
# This overrides the placeholder value set in prod.exs # This overrides the placeholder value set in prod.exs
# Supports TOKEN_SIGNING_SECRET or TOKEN_SIGNING_SECRET_FILE for Docker secrets.
token_signing_secret = token_signing_secret =
System.get_env("TOKEN_SIGNING_SECRET") || get_env_or_file!.("TOKEN_SIGNING_SECRET", """
raise """ environment variable TOKEN_SIGNING_SECRET (or TOKEN_SIGNING_SECRET_FILE) is missing.
environment variable TOKEN_SIGNING_SECRET is missing.
You can generate one by calling: mix phx.gen.secret You can generate one by calling: mix phx.gen.secret
""" """)
config :mv, :token_signing_secret, token_signing_secret config :mv, :token_signing_secret, token_signing_secret
config :mv, MvWeb.Endpoint, config :mv, MvWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"], url: [host: host, port: 443, scheme: "https"],
http: [ http: [
# Enable IPv6 and bind on all interfaces. # Bind on all IPv4 interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. # Use {0, 0, 0, 0, 0, 0, 0, 0} for IPv6, or {127, 0, 0, 1} for localhost only.
# See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0 # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
# for details about using IPv6 vs IPv4 and loopback vs public addresses. ip: {0, 0, 0, 0},
ip: {0, 0, 0, 0, 0, 0, 0, 0},
port: port port: port
], ],
secret_key_base: secret_key_base, secret_key_base: secret_key_base,

View file

@ -2,21 +2,32 @@ services:
app: app:
image: git.local-it.org/local-it/mitgliederverwaltung:latest image: git.local-it.org/local-it/mitgliederverwaltung:latest
container_name: mv-prod-app container_name: mv-prod-app
# Use host network for local testing to access localhost:8080 (Rauthy) ports:
# In real production, remove this and use external OIDC provider - "4001:4001"
network_mode: host
environment: environment:
DATABASE_URL: "ecto://postgres:postgres@localhost:5001/mv_prod" # Database configuration using separate variables
SECRET_KEY_BASE: "${SECRET_KEY_BASE}" # Use Docker service name for internal networking
TOKEN_SIGNING_SECRET: "${TOKEN_SIGNING_SECRET}" DATABASE_HOST: "db-prod"
PHX_HOST: "${PHX_HOST}" DATABASE_PORT: "5432"
DATABASE_USER: "postgres"
DATABASE_NAME: "mv_prod"
DATABASE_PASSWORD_FILE: "/run/secrets/db_password"
# Phoenix secrets via Docker secrets
SECRET_KEY_BASE_FILE: "/run/secrets/secret_key_base"
TOKEN_SIGNING_SECRET_FILE: "/run/secrets/token_signing_secret"
PHX_HOST: "${PHX_HOST:-localhost}"
PORT: "4001" PORT: "4001"
PHX_SERVER: "true" PHX_SERVER: "true"
# Rauthy OIDC config - uses localhost because of host network mode # Rauthy OIDC config - use host.docker.internal to reach host services
OIDC_CLIENT_ID: "mv" OIDC_CLIENT_ID: "mv"
OIDC_BASE_URL: "http://localhost:8080/auth/v1" OIDC_BASE_URL: "http://host.docker.internal:8080/auth/v1"
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}" OIDC_CLIENT_SECRET_FILE: "/run/secrets/oidc_client_secret"
OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/rauthy/callback" OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/rauthy/callback"
secrets:
- db_password
- secret_key_base
- token_signing_secret
- oidc_client_secret
depends_on: depends_on:
- db-prod - db-prod
restart: unless-stopped restart: unless-stopped
@ -26,13 +37,25 @@ services:
container_name: mv-prod-db container_name: mv-prod-db
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD_FILE: /run/secrets/db_password
POSTGRES_DB: mv_prod POSTGRES_DB: mv_prod
secrets:
- db_password
volumes: volumes:
- postgres_data_prod:/var/lib/postgresql/data - postgres_data_prod:/var/lib/postgresql/data
ports: ports:
- "5001:5432" - "5001:5432"
restart: unless-stopped restart: unless-stopped
secrets:
db_password:
file: ./secrets/db_password.txt
secret_key_base:
file: ./secrets/secret_key_base.txt
token_signing_secret:
file: ./secrets/token_signing_secret.txt
oidc_client_secret:
file: ./secrets/oidc_client_secret.txt
volumes: volumes:
postgres_data_prod: postgres_data_prod: