add Docker deployment with Alpine image, release config and health check

- Alpine multi-stage Dockerfile (131 MB image)
- Release overlays (bin/server, bin/migrate), env.sh, Release module
- Health check endpoint at GET /health
- Fly.io config with SQLite volume mount
- Fix hardcoded paths in optimizer.ex and variant_cache.ex to use
  Application.app_dir/2 (breaks in releases where Plug.Static serves
  from a different directory than CWD)
- strip_beams: true in release config
- Optimised .dockerignore and .gitignore for mockup variants

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-08 16:21:05 +00:00
parent eaa4bbb3fa
commit 1ee37c853d
15 changed files with 320 additions and 32 deletions

52
.dockerignore Normal file
View File

@ -0,0 +1,52 @@
# Build artifacts
_build/
deps/
.elixir_ls/
# Git
.git/
.gitignore
# Test and dev
test/
.formatter.exs
.credo.exs
.dialyzer_ignore.exs
# Database files
*.db
*.db-wal
*.db-shm
# Docker
Dockerfile*
.dockerignore
docker-compose*
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# CI/docs
CLAUDE.md
PROGRESS.md
ROADMAP.md
docs/
# Generated image variants (regenerated on startup from DB)
priv/static/image_cache/
# Mockup variants, digested copies, and converted formats.
# Only the source .webp files (no size suffix) should enter the build context —
# phx.digest creates its own hash-suffixed copies inside the builder.
priv/static/mockups/*-400*
priv/static/mockups/*-800*
priv/static/mockups/*-1200*
priv/static/mockups/*-thumb*
priv/static/mockups/*.avif
priv/static/mockups/*.jpg
priv/static/mockups/*.gz

16
.gitignore vendored
View File

@ -49,13 +49,15 @@ simpleshop_theme-*.tar
/priv/static/images/*-*.svg.gz
/priv/static/images/*.gz
# Generated mockup variants (auto-generated on startup via Oban)
# Source .webp files are tracked, variants are regenerated
/priv/static/mockups/*-400.*
/priv/static/mockups/*-800.*
/priv/static/mockups/*-1200.*
/priv/static/mockups/*-thumb.*
# Digested versions of mockup variants and sources (from mix phx.digest)
# Generated mockup variants, digested copies, and converted formats.
# Only source .webp files (no size suffix) are tracked — everything else regenerates.
/priv/static/mockups/*-400*
/priv/static/mockups/*-800*
/priv/static/mockups/*-1200*
/priv/static/mockups/*-thumb*
/priv/static/mockups/*.avif
/priv/static/mockups/*.jpg
/priv/static/mockups/*.gz
/priv/static/mockups/*-????????????????????????????????.*
# Generated image variants cache (regenerated from source images)

113
Dockerfile Normal file
View File

@ -0,0 +1,113 @@
# Lean Alpine-based image for SimpleshopTheme.
#
# Builder and runner both use Alpine (musl). The vix NIF is compiled against
# Alpine's system libvips instead of the glibc-linked precompiled binary.
#
# Build: docker build -t simpleshop_theme .
# Run: docker run --rm -p 4000:4000 \
# -e SECRET_KEY_BASE=$(mix phx.gen.secret) \
# -e DATABASE_PATH=/data/simpleshop_theme.db \
# -e PHX_HOST=localhost \
# -v simpleshop_data:/data \
# simpleshop_theme
#
# Images:
# https://hub.docker.com/r/hexpm/elixir/tags?name=alpine
# https://hub.docker.com/_/alpine
ARG ELIXIR_VERSION=1.19.5
ARG OTP_VERSION=28.2
ARG ALPINE_VERSION=3.23.3
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-alpine-${ALPINE_VERSION}"
ARG RUNNER_IMAGE="alpine:${ALPINE_VERSION}"
# --- Builder ---
FROM ${BUILDER_IMAGE} AS builder
# Build deps: C compiler for NIFs, git for hex deps, vips-dev for image processing
RUN apk add --no-cache build-base git vips-dev
WORKDIR /app
RUN mix local.hex --force && mix local.rebar --force
ENV MIX_ENV="prod"
# Use Alpine's system libvips instead of the glibc-linked precompiled binary
ENV VIX_COMPILATION_MODE="PLATFORM_PROVIDED_LIBVIPS"
# Install mix dependencies
COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV
RUN mkdir config
# Compile dependencies (config needed first)
COPY config/config.exs config/${MIX_ENV}.exs config/
RUN mix deps.compile
# Install tailwind and esbuild CLI tools
RUN mix assets.setup
# Copy priv in layers ordered by change frequency.
# Mockups/fonts are stable; migrations and seeds change more often.
COPY priv/static/mockups priv/static/mockups
COPY priv/static/fonts priv/static/fonts
COPY priv/static/images priv/static/images
COPY priv/static/favicon.ico priv/static/robots.txt priv/static/
COPY priv/repo priv/repo
COPY priv/gettext priv/gettext
# Application code and frontend assets (change most often)
COPY lib lib
COPY assets assets
# Compile the application
RUN mix compile
# Build assets (both tailwind profiles + esbuild + phx.digest)
RUN mix assets.deploy
# Remove gzipped copies of mockup images — WebP is already compressed,
# gzip saves ~0% and just wastes space in the release
RUN find priv/static/mockups -name "*.gz" -delete 2>/dev/null || true
# Runtime config doesn't need recompilation
COPY config/runtime.exs config/
# Release overlay scripts
COPY rel rel
# Build the release
RUN mix release
# --- Runner ---
FROM ${RUNNER_IMAGE} AS runner
# Runtime deps only — no compilers, no -dev packages
RUN apk add --no-cache libstdc++ openssl ncurses-libs vips
WORKDIR /app
# Non-root user for security
RUN adduser -D -h /app appuser
RUN chown appuser:appuser /app
# Prepare the data directory (for SQLite database)
RUN mkdir -p /data && chown appuser:appuser /data
ENV MIX_ENV="prod"
# Copy the release from the builder
COPY --from=builder --chown=appuser:appuser /app/_build/${MIX_ENV}/rel/simpleshop_theme ./
USER appuser
# Ensure /data is the default database location
ENV DATABASE_PATH="/data/simpleshop_theme.db"
EXPOSE 4000
CMD ["/app/bin/server"]

View File

@ -33,7 +33,7 @@
### Tier 2 — Production readiness (can deploy and run reliably)
5. **Hosting & deployment**Fly.io or similar deployment config. Release configuration, runtime env setup, health checks. Observability: structured logging, error tracking (Sentry or similar), basic metrics.
5. **Hosting & deployment**In progress. Alpine Docker image (131 MB), Fly.io config, release overlays, health check endpoint, hardcoded path fixes for releases. Still to do: observability (structured logging, error tracking, basic metrics).
6. **Litestream / SQLite replication** — Litestream for continuous SQLite backup to S3-compatible storage. Point-in-time recovery. Simple sidecar process, no code changes needed, works with vanilla SQLite. For the hosted platform (Tier 5), evaluate [Turso](https://turso.tech/) (libSQL fork of SQLite) with embedded read replicas via [ecto_libsql](https://github.com/ocean/ecto_libsql) adapter — gives multi-node reads without a separate replication daemon, but adds a dependency on the libSQL fork.
7. ~~**CI pipeline**~~ — ✅ Complete. `mix ci` alias: compile --warning-as-errors, deps.unlock --unused, format --check-formatted, credo, dialyzer, test. Credo configured with sensible defaults. Dialyzer with ignore file for false positives (Stripe types, Mix tasks, ExUnit internals). 612 tests, 0 failures.
8. **PageSpeed in CI** — Lighthouse CI to catch regressions. Fail the build if score drops below threshold. Protects the current 100% score.
@ -56,7 +56,7 @@
17. **Hosted platform** — Marketing/brochure site for SimpleShop as a service. Subscribe/sign-up flow. Multi-tenancy with per-tenant databases. Stripe Connect for customer shops (each merchant connects their own Stripe account via OAuth).
18. **Migration & export** — Let shop owners export their data (products, orders, customers, theme settings). Import from other platforms (Shopify, WooCommerce). Portable data as a selling point for the self-hosted story.
19. **Internationalisation (i18n)** — Multi-language support via Gettext (already in Phoenix). Currency formatting (ex_cldr already in deps). RTL layout support. Per-shop locale configuration.
19. **Internationalisation (i18n)** — Multi-language support via Gettext (already in Phoenix). Currency formatting. RTL layout support. Per-shop locale configuration. **Note:** `ex_money`/`ex_cldr` are currently used *only* for `Cart.format_price/1` (a single GBP formatting call) but add ~13 MB to the release (ex_cldr 9.5 MB, digital_token 3.7 MB, ex_cldr_numbers, ex_cldr_currencies). Consider replacing with a simple `format_price/2` function that handles GBP/EUR/USD directly — all three use 2 decimal places and are trivial to format. Re-add `ex_money` later if proper locale-aware number formatting is needed (e.g., German `12.345,67 €`).
---

View File

@ -25,7 +25,7 @@ if config_env() == :prod do
System.get_env("DATABASE_PATH") ||
raise """
environment variable DATABASE_PATH is missing.
For example: /etc/simpleshop_theme/simpleshop_theme.db
For example: /data/simpleshop_theme.db
"""
config :simpleshop_theme, SimpleshopTheme.Repo,
@ -97,22 +97,32 @@ if config_env() == :prod do
# ## Configuring the mailer
#
# In production you need to configure the mailer to use a different adapter.
# Here is an example configuration for Mailgun:
# Uncomment ONE of the blocks below and set the matching env vars.
# The API client is already configured in config/prod.exs (Swoosh.ApiClient.Req).
#
# Postmark (recommended):
#
# config :simpleshop_theme, SimpleshopTheme.Mailer,
# adapter: Swoosh.Adapters.Postmark,
# api_key: System.get_env("POSTMARK_API_KEY")
#
# Mailgun:
#
# config :simpleshop_theme, SimpleshopTheme.Mailer,
# adapter: Swoosh.Adapters.Mailgun,
# api_key: System.get_env("MAILGUN_API_KEY"),
# domain: System.get_env("MAILGUN_DOMAIN")
#
# Most non-SMTP adapters require an API client. Swoosh supports Req, Hackney,
# and Finch out-of-the-box. This configuration is typically done at
# compile-time in your config/prod.exs:
# SMTP (any provider):
#
# config :swoosh, :api_client, Swoosh.ApiClient.Req
#
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
# config :simpleshop_theme, SimpleshopTheme.Mailer,
# adapter: Swoosh.Adapters.SMTP,
# relay: System.get_env("SMTP_HOST"),
# port: String.to_integer(System.get_env("SMTP_PORT") || "587"),
# username: System.get_env("SMTP_USERNAME"),
# password: System.get_env("SMTP_PASSWORD"),
# tls: :if_available
# Stripe keys are stored encrypted in the database and loaded at runtime
# by SimpleshopTheme.Secrets. No env vars needed.
# Stripe and Printify keys are stored encrypted in the database and loaded
# at runtime by SimpleshopTheme.Secrets. No env vars needed for those.
end

50
fly.toml Normal file
View File

@ -0,0 +1,50 @@
# fly.toml — Fly.io deployment configuration
#
# Getting started:
# 1. Install flyctl: curl -L https://fly.io/install.sh | sh
# 2. Sign up / log in: fly auth login
# 3. Create the app: fly launch --no-deploy
# 4. Create a volume: fly volumes create simpleshop_data --size 1
# 5. Set secrets:
# fly secrets set SECRET_KEY_BASE=$(mix phx.gen.secret)
# fly secrets set PHX_HOST=your-domain.com
# 6. Deploy: fly deploy
# 7. Open: fly open
app = "simpleshop-theme"
primary_region = "lhr"
[build]
dockerfile = "Dockerfile"
# SQLite needs a persistent volume — without this, your database is lost on every deploy.
[mounts]
source = "simpleshop_data"
destination = "/data"
[env]
PHX_SERVER = "true"
DATABASE_PATH = "/data/simpleshop_theme.db"
[http_service]
internal_port = 4000
force_https = true
auto_stop_machines = "stop"
auto_start_machines = true
min_machines_running = 0
[http_service.concurrency]
type = "connections"
hard_limit = 1000
soft_limit = 1000
[[http_service.checks]]
grace_period = "30s"
interval = "15s"
method = "GET"
timeout = "5s"
path = "/health"
[[vm]]
size = "shared-cpu-1x"
memory = "1gb"

View File

@ -13,11 +13,10 @@ defmodule SimpleshopTheme.Images.Optimizer do
# Only affects <5% of users (legacy browsers without AVIF/WebP support)
@pregenerated_formats [:avif, :webp]
@thumb_size 200
@cache_dir "priv/static/image_cache"
@max_stored_width 2000
@storage_quality 90
def cache_dir, do: @cache_dir
def cache_dir, do: Application.app_dir(:simpleshop_theme, "priv/static/image_cache")
def all_widths, do: @all_widths
@doc """
@ -72,7 +71,7 @@ defmodule SimpleshopTheme.Images.Optimizer do
{:ok, :svg_skipped}
%{data: data, source_width: width} = image ->
File.mkdir_p!(@cache_dir)
File.mkdir_p!(cache_dir())
with {:ok, vips_image} <- Image.from_binary(data) do
widths = applicable_widths(width)
@ -93,7 +92,7 @@ defmodule SimpleshopTheme.Images.Optimizer do
end
defp generate_thumbnail(image, id) do
path = Path.join(@cache_dir, "#{id}-thumb.jpg")
path = Path.join(cache_dir(), "#{id}-thumb.jpg")
return_if_exists(path, fn ->
with {:ok, thumb} <- Image.thumbnail(image, @thumb_size),
@ -104,7 +103,7 @@ defmodule SimpleshopTheme.Images.Optimizer do
end
defp generate_variant(image, id, width, format) do
path = Path.join(@cache_dir, "#{id}-#{width}.#{format_ext(format)}")
path = Path.join(cache_dir(), "#{id}-#{width}.#{format_ext(format)}")
return_if_exists(path, fn ->
with {:ok, resized} <- Image.thumbnail(image, width),
@ -140,12 +139,12 @@ defmodule SimpleshopTheme.Images.Optimizer do
"""
def disk_variants_exist?(image_id, source_width) do
widths = applicable_widths(source_width)
thumb = File.exists?(Path.join(@cache_dir, "#{image_id}-thumb.jpg"))
thumb = File.exists?(Path.join(cache_dir(), "#{image_id}-thumb.jpg"))
variants =
Enum.all?(widths, fn w ->
Enum.all?(@pregenerated_formats, fn fmt ->
File.exists?(Path.join(@cache_dir, "#{image_id}-#{w}.#{format_ext(fmt)}"))
File.exists?(Path.join(cache_dir(), "#{image_id}-#{w}.#{format_ext(fmt)}"))
end)
end)
@ -159,12 +158,12 @@ defmodule SimpleshopTheme.Images.Optimizer do
"""
def generate_variant_on_demand(image_data, image_id, width, format)
when is_binary(image_data) and format in [:avif, :webp, :jpg] do
path = Path.join(@cache_dir, "#{image_id}-#{width}.#{format_ext(format)}")
path = Path.join(cache_dir(), "#{image_id}-#{width}.#{format_ext(format)}")
if File.exists?(path) do
{:ok, path}
else
File.mkdir_p!(@cache_dir)
File.mkdir_p!(cache_dir())
with {:ok, vips_image} <- Image.from_binary(image_data),
{:ok, resized} <- Image.thumbnail(vips_image, width),

View File

@ -19,7 +19,7 @@ defmodule SimpleshopTheme.Images.VariantCache do
alias SimpleshopTheme.Sync.ImageDownloadWorker
import Ecto.Query
@mockup_dir "priv/static/mockups"
defp mockup_dir, do: Application.app_dir(:simpleshop_theme, "priv/static/mockups")
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
@ -87,9 +87,9 @@ defmodule SimpleshopTheme.Images.VariantCache do
end
defp ensure_mockup_variants do
if File.dir?(@mockup_dir) do
if File.dir?(mockup_dir()) do
sources =
Path.wildcard(Path.join(@mockup_dir, "*.webp"))
Path.wildcard(Path.join(mockup_dir(), "*.webp"))
|> Enum.reject(&is_variant?/1)
missing = Enum.reject(sources, &mockup_variants_exist?/1)

View File

@ -0,0 +1,32 @@
defmodule SimpleshopTheme.Release do
@moduledoc """
Release tasks that can be run via `bin/migrate` or `bin/simpleshop_theme eval`.
Migrations run automatically on startup (see Application), so this is mainly
useful as a standalone tool for debugging or manual recovery.
"""
@app :simpleshop_theme
def migrate do
load_app()
for repo <- repos() do
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
end
end
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.ensure_all_started(:ssl)
Application.load(@app)
end
end

View File

@ -0,0 +1,7 @@
defmodule SimpleshopThemeWeb.HealthController do
use SimpleshopThemeWeb, :controller
def show(conn, _params) do
json(conn, %{status: "ok"})
end
end

View File

@ -52,6 +52,13 @@ defmodule SimpleshopThemeWeb.Router do
post "/checkout", CheckoutController, :create
end
# Health check (no auth, no theme loading — for load balancers and uptime monitors)
scope "/", SimpleshopThemeWeb do
pipe_through [:api]
get "/health", HealthController, :show
end
# Cart API (session persistence for LiveView)
scope "/api", SimpleshopThemeWeb do
pipe_through [:browser]

View File

@ -10,6 +10,7 @@ defmodule SimpleshopTheme.MixProject do
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps(),
releases: [simpleshop_theme: [strip_beams: true]],
compilers: [:phoenix_live_view] ++ Mix.compilers(),
listeners: [Phoenix.CodeReloader],
dialyzer: [ignore_warnings: ".dialyzer_ignore.exs"]

7
rel/env.sh.eex Normal file
View File

@ -0,0 +1,7 @@
#!/bin/sh
# Single-node by default (no distributed Erlang)
export RELEASE_DISTRIBUTION=none
# Writable temp dir for the BEAM
export RELEASE_TMP="${RELEASE_TMP:-/tmp}"

4
rel/overlays/bin/migrate Executable file
View File

@ -0,0 +1,4 @@
#!/bin/sh
set -eu
exec bin/simpleshop_theme eval "SimpleshopTheme.Release.migrate()"

4
rel/overlays/bin/server Executable file
View File

@ -0,0 +1,4 @@
#!/bin/sh
set -eu
PHX_SERVER=true exec bin/simpleshop_theme start