From 1ee37c853d2d94e514cf7bf5895785ec1d4e03f5 Mon Sep 17 00:00:00 2001 From: jamey Date: Sun, 8 Feb 2026 16:21:05 +0000 Subject: [PATCH] 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 --- .dockerignore | 52 ++++++++ .gitignore | 16 +-- Dockerfile | 113 ++++++++++++++++++ PROGRESS.md | 4 +- config/runtime.exs | 32 +++-- fly.toml | 50 ++++++++ lib/simpleshop_theme/images/optimizer.ex | 17 ++- lib/simpleshop_theme/images/variant_cache.ex | 6 +- lib/simpleshop_theme/release.ex | 32 +++++ .../controllers/health_controller.ex | 7 ++ lib/simpleshop_theme_web/router.ex | 7 ++ mix.exs | 1 + rel/env.sh.eex | 7 ++ rel/overlays/bin/migrate | 4 + rel/overlays/bin/server | 4 + 15 files changed, 320 insertions(+), 32 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 fly.toml create mode 100644 lib/simpleshop_theme/release.ex create mode 100644 lib/simpleshop_theme_web/controllers/health_controller.ex create mode 100644 rel/env.sh.eex create mode 100755 rel/overlays/bin/migrate create mode 100755 rel/overlays/bin/server diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5c05ee1 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitignore b/.gitignore index d2be3f4..e176424 100644 --- a/.gitignore +++ b/.gitignore @@ -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) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c2d8579 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/PROGRESS.md b/PROGRESS.md index 70b4088..0f220cd 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 €`). --- diff --git a/config/runtime.exs b/config/runtime.exs index 807db6e..800c41d 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -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 diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..4f1b19d --- /dev/null +++ b/fly.toml @@ -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" diff --git a/lib/simpleshop_theme/images/optimizer.ex b/lib/simpleshop_theme/images/optimizer.ex index 79a4b2f..8e34ad9 100644 --- a/lib/simpleshop_theme/images/optimizer.ex +++ b/lib/simpleshop_theme/images/optimizer.ex @@ -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), diff --git a/lib/simpleshop_theme/images/variant_cache.ex b/lib/simpleshop_theme/images/variant_cache.ex index 54d416c..55d3b5a 100644 --- a/lib/simpleshop_theme/images/variant_cache.ex +++ b/lib/simpleshop_theme/images/variant_cache.ex @@ -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) diff --git a/lib/simpleshop_theme/release.ex b/lib/simpleshop_theme/release.ex new file mode 100644 index 0000000..849b8b4 --- /dev/null +++ b/lib/simpleshop_theme/release.ex @@ -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 diff --git a/lib/simpleshop_theme_web/controllers/health_controller.ex b/lib/simpleshop_theme_web/controllers/health_controller.ex new file mode 100644 index 0000000..520f5f1 --- /dev/null +++ b/lib/simpleshop_theme_web/controllers/health_controller.ex @@ -0,0 +1,7 @@ +defmodule SimpleshopThemeWeb.HealthController do + use SimpleshopThemeWeb, :controller + + def show(conn, _params) do + json(conn, %{status: "ok"}) + end +end diff --git a/lib/simpleshop_theme_web/router.ex b/lib/simpleshop_theme_web/router.ex index bb3d890..70f0e5b 100644 --- a/lib/simpleshop_theme_web/router.ex +++ b/lib/simpleshop_theme_web/router.ex @@ -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] diff --git a/mix.exs b/mix.exs index 68c29db..d20e534 100644 --- a/mix.exs +++ b/mix.exs @@ -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"] diff --git a/rel/env.sh.eex b/rel/env.sh.eex new file mode 100644 index 0000000..2c8c016 --- /dev/null +++ b/rel/env.sh.eex @@ -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}" diff --git a/rel/overlays/bin/migrate b/rel/overlays/bin/migrate new file mode 100755 index 0000000..1c54053 --- /dev/null +++ b/rel/overlays/bin/migrate @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu + +exec bin/simpleshop_theme eval "SimpleshopTheme.Release.migrate()" diff --git a/rel/overlays/bin/server b/rel/overlays/bin/server new file mode 100755 index 0000000..c2b2b76 --- /dev/null +++ b/rel/overlays/bin/server @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu + +PHX_SERVER=true exec bin/simpleshop_theme start