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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd -P -- "$(dirname -- "$0")"
|
||||
exec ./bulwark eval Bulwark.Release.migrate
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd -P -- "$(dirname -- "$0")"
|
||||
PHX_SERVER=true exec ./bulwark start
|
||||
Reference in New Issue
Block a user