build: add release image, compose backing services, and env scaffolding

- Multi-stage Dockerfile (mix release, embedded ERTS, runs as nobody) with
  migrate/server release overlays for an initContainer migration flow
- docker-compose: standalone-friendly upstream images (postgres:17,
  valkey/valkey:8) for the local dev loop. The cluster's operator images
  (cloudnative-pg, hyperspike) don't run standalone — DSN-from-env already
  gives local<->cluster parity, so image identity is local-only
- .env(.local).example templates + .envrc (direnv) loader
- .dockerignore; .gitignore fixups for release artifacts and env files
This commit is contained in:
Christopher Fahlin
2026-05-30 19:07:13 -07:00
parent 0827168616
commit ea814c5125
10 changed files with 258 additions and 5 deletions
+44
View File
@@ -0,0 +1,44 @@
# Build artifacts — rebuilt inside the image.
/_build/
/deps/
/.fetch
*.ez
bulwark-*.tar
# Digested/static output — regenerated by mix assets.deploy.
/priv/static/assets/
/priv/static/cache_manifest.json
# Uploaded artifacts — runtime data, never baked in.
/priv/uploads/
# Tests, docs, dev-only.
/test/
/doc/
/cover/
/tmp/
/samples/
# Node modules (if any sneak in).
/assets/node_modules/
# Env / secrets — injected at runtime, never in the image.
.env
.env.*
!.env.example
!.env.local.example
# Local tooling.
.git/
.gitignore
.elixir_ls/
.vscode/
.idea/
.DS_Store
/priv/plts/
.claude/
.codegraph/
README.md
AGENTS.md
CLAUDE.md
DESIGN.md
+26
View File
@@ -0,0 +1,26 @@
# Production env template — copy to .env (gitignored) and fill from secrets.
#
# DO NOT commit real credentials. In the cluster these values come from
# SOPS-encrypted Secrets (shared-pg-app, shared-cache-auth), not this file.
# This template documents the SHAPE only.
# Required.
SECRET_KEY_BASE= # mix phx.gen.secret
PHX_HOST=bulwark.example.com
PORT=4000
PHX_SERVER=true
# Postgres — cluster primary (-rw always points at the writer).
# Password is URL-safe hex (see the developer guide: avoid / + in DSNs).
DATABASE_URL=postgresql://app:<password>@shared-pg-rw.databases.svc.cluster.local:5432/app
POOL_SIZE=10
# Valkey — credentialed form (cluster requires auth). Optional: omit to run
# the app with the cache disabled (degrades gracefully to Postgres).
VALKEY_URL=redis://:<password>@shared-cache.databases.svc.cluster.local:6379/0
# Set if connecting over IPv6 (e.g. some cluster networking).
# ECTO_IPV6=true
# Optional: libcluster DNS query for multi-node BEAM clustering.
# DNS_CLUSTER_QUERY=bulwark-headless.web.svc.cluster.local
+19
View File
@@ -0,0 +1,19 @@
# Local development env — copy to .env.local (gitignored).
#
# Points the app at the throwaway Postgres + Valkey from docker-compose.yml.
# The DSN is the ONLY thing that differs from prod; read it from the env so the
# local -> cluster move is a deploy, not a rewrite.
# --- docker-compose backing services ---
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=bulwark_dev
# --- app connection strings ---
# Postgres: local container.
DATABASE_URL=ecto://postgres:postgres@localhost:5432/bulwark_dev
# Valkey: local container, NO password (cluster requires one — see auth-parity
# note in the developer guide). Always pass the full DSN via env so the
# credentialed prod form flows through unchanged.
VALKEY_URL=redis://localhost:6379/0
+6
View File
@@ -0,0 +1,6 @@
# direnv: load local dev env. Run `direnv allow` once after editing.
# .env.local is gitignored (copy from .env.local.example); secrets never
# committed. The DSN-from-env is the only thing that differs local↔cluster.
if [ -f .env.local ]; then
dotenv .env.local
fi
+5 -2
View File
@@ -3,7 +3,7 @@
/deps/
/.fetch
*.ez
sec_dashboard-*.tar
bulwark-*.tar
# Test / docs
/cover/
@@ -33,6 +33,7 @@ npm-debug.log
.env
.env.*
!.env.example
!.env.local.example
# Editor / IDE
.vscode/
@@ -47,11 +48,13 @@ Thumbs.db
/priv/plts/
# Releases
/rel/overlays/
/_rel/
# Claude Code
.claude/
# Local deploy notes (regenerate in the infra repo session)
DEPLOYMENT.md
# CodeGraph index
.codegraph/
+72
View File
@@ -0,0 +1,72 @@
# Multi-stage build producing a self-contained mix release.
#
# Builder: full Elixir/OTP toolchain + Node-free asset pipeline (esbuild +
# tailwind are fetched by mix tasks). Runtime: debian-slim with only the
# OpenSSL/ncurses libs the BEAM needs. The release embeds ERTS, so no Elixir is
# installed in the runtime image.
ARG ELIXIR_VERSION=1.18.4
ARG OTP_VERSION=27.3.4
ARG DEBIAN_VERSION=bookworm-20250630-slim
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
# ---- build stage ----
FROM ${BUILDER_IMAGE} AS builder
RUN apt-get update -y \
&& apt-get install -y build-essential git \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN mix local.hex --force && mix local.rebar --force
ENV MIX_ENV="prod"
# Dependencies first for layer caching.
COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV
RUN mkdir config
COPY config/config.exs config/${MIX_ENV}.exs config/
RUN mix deps.compile
COPY priv priv
COPY lib lib
COPY assets assets
# Build digested assets, then compile.
RUN mix assets.deploy
RUN mix compile
COPY config/runtime.exs config/
COPY rel rel
RUN mix release
# ---- runtime stage ----
FROM ${RUNNER_IMAGE}
RUN apt-get update -y \
&& apt-get install -y libstdc++6 openssl libncurses6 locales ca-certificates \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# UTF-8 locale (the BEAM needs it for string handling).
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8
WORKDIR /app
RUN chown nobody /app
ENV MIX_ENV="prod"
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/bulwark ./
USER nobody
EXPOSE 4000
# Default: run migrations, then start the server.
CMD ["/bin/sh", "-c", "/app/bin/migrate && /app/bin/server"]
+41 -3
View File
@@ -1,13 +1,51 @@
# Local backing services for the dev loop.
#
# Runs throwaway Postgres + Valkey on your machine; the app runs on the host
# (`mix phx.server`) and connects over localhost. This mirrors the cluster
# topology in miniature — only the DSN differs between local and prod. See the
# developer guide ("Local development") for the rationale.
#
# Images: canonical upstream images pinned to the cluster's MAJOR versions.
# We deliberately do NOT use the cluster's operator images here:
# shared-pg -> cloudnative-pg/postgresql (assumes a k8s-managed PVC; does
# not chown its data dir, so it fails on a plain Docker volume)
# shared-cache -> hyperspike/valkey (ships Cmd=/bin/sh; the Valkey operator
# injects the server command — it never starts standalone)
# DSN-from-env already gives us local↔cluster parity, so the local image
# identity is irrelevant. Use the standalone-friendly upstream images and pin
# the same MAJOR (PG 17, Valkey 8) to avoid behavioral drift.
#
# Env comes from .env.local (gitignored). Copy .env.local.example to start.
services:
postgres:
image: postgres:17-alpine
image: postgres:17
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-bulwark_dev}
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
interval: 5s
timeout: 3s
retries: 10
valkey:
image: valkey/valkey:8
# No password locally — cluster Valkey REQUIRES one. The DSN (with creds in
# prod) is the only thing that changes; see the auth-parity note in the
# developer guide. Do not hard-code the no-auth form in app code.
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
volumes:
pgdata:
+37
View File
@@ -0,0 +1,37 @@
defmodule Bulwark.Release do
@moduledoc """
Release tasks invoked from the built release (no Mix available in prod).
`bin/bulwark eval "Bulwark.Release.migrate()"` runs pending migrations; the
`rel/overlays/bin/migrate` and `server` scripts wrap this for the container
entrypoint.
"""
@app :bulwark
@doc "Runs all pending migrations for every configured repo."
@spec migrate() :: :ok
def migrate do
load_app()
for repo <- repos() do
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
end
:ok
end
@doc "Rolls `repo` back to migration version `version`."
@spec rollback(module(), integer()) :: {:ok, term(), term()}
def rollback(repo, version) do
load_app()
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end
defp repos do
Application.fetch_env!(@app, :ecto_repos)
end
defp load_app do
Application.load(@app)
end
end
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
set -eu
cd -P -- "$(dirname -- "$0")"
exec ./bulwark eval Bulwark.Release.migrate
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
set -eu
cd -P -- "$(dirname -- "$0")"
PHX_SERVER=true exec ./bulwark start