Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.env
.elixir_ls
.git
.github
_build
data
deps
erl_crash.dump
node_modules
tmp
52 changes: 44 additions & 8 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,17 +1,53 @@
FROM elixir:1.17.2
FROM elixir:1.17.2 AS base

ENV MIX_ENV=prod

FROM base AS build

RUN apt-get update && \
apt-get install -y \
apt-get install -y --no-install-recommends \
build-essential \
ffmpeg \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
git \
&& apt-get clean && \
rm -rf /var/lib/apt/lists/*

WORKDIR /app

RUN mix local.hex --force && \
mix local.rebar --force

COPY mix.exs mix.lock ./
RUN mix do local.hex --force, local.rebar --force, deps.get
COPY config config

RUN mix deps.get --only $MIX_ENV
RUN mix deps.compile

COPY lib lib
COPY priv priv

RUN mix compile
RUN mix release

FROM debian:bookworm-slim AS runtime

RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
ffmpeg \
libncurses6 \
libstdc++6 \
openssl \
&& apt-get clean && \
rm -rf /var/lib/apt/lists/*

ENV LANG=C.UTF-8

WORKDIR /app

RUN useradd --system --create-home --home-dir /app save_it

COPY --from=build --chown=save_it:save_it /app/_build/prod/rel/save_it /app

COPY . .
USER save_it

CMD ["mix", "run", "--no-halt"]
CMD ["/app/bin/save_it", "start"]
2 changes: 1 addition & 1 deletion config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ config :save_it, :google_oauth_client_secret, System.get_env("GOOGLE_OAUTH_CLIEN

config :sentry,
dsn: System.get_env("SENTRY_DSN"),
environment_name: Mix.env(),
environment_name: config_env(),
enable_source_code_context: true,
root_source_code_path: File.cwd!(),
included_dependencies: [:req, :jason, :hackney]
17 changes: 16 additions & 1 deletion docs/deployment/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,19 @@ GOOGLE_OAUTH_CLIENT_SECRET=

- Published image: `ghcr.io/thaddeusjiang/save_it`
- Zeabur template image tag: `latest`
- Local acceptance build check: `mise run confirm-docker-build`
- Local acceptance build check: `mise run build-acceptance-test`
- The container now boots from the Elixir release entrypoint: `/app/bin/save_it start`

## Release Commands

- Run Typesense migration in a release container:

```sh
/app/bin/save_it eval 'SaveIt.Release.ts_migrate()'
```

- Example with Docker:

```sh
docker exec -it <container_name> /app/bin/save_it eval 'SaveIt.Release.ts_migrate()'
```
60 changes: 1 addition & 59 deletions lib/mix/tasks/ts.migrate.ex
Original file line number Diff line number Diff line change
@@ -1,71 +1,13 @@
defmodule Mix.Tasks.Ts.Migrate do
use Mix.Task

alias Req.TransportError
alias SaveIt.Migration.Typesense
alias SaveIt.Migration.Typesense.Photo

@shortdoc "Run Typesense collection migrations"

@impl Mix.Task
def run(_args) do
Mix.Task.run("app.start")

typesense_url = Application.fetch_env!(:save_it, :typesense_url)

try do
ensure_photos_collection!()
migrate_photos_if_needed!()

Mix.shell().info("Typesense migration done")
rescue
error in TransportError ->
raise """
Typesense request failed: #{Exception.message(error)}
Current TYPESENSE_URL: #{typesense_url}

If you are using docker-compose locally, try:
export TYPESENSE_URL=http://localhost:8108
docker compose up -d typesense
mix ts.migrate
"""
end
end

defp ensure_photos_collection! do
case photos_collection() do
nil ->
Mix.shell().info("creating photos collection")
Photo.create_photos_20241024!()

_collection ->
Mix.shell().info("photos collection already exists, skipping create")
end
end

defp migrate_photos_if_needed! do
case photos_collection() do
nil ->
:ok

collection ->
if has_field?(collection, "url") do
Mix.shell().info("applying photos migration 20241029")
Photo.migrate_photos_20241029!()
else
Mix.shell().info("photos migration already applied, skipping")
end
end
end

defp photos_collection do
Typesense.list_collections!()
|> Enum.find(fn collection -> collection["name"] == "photos" end)
end

defp has_field?(collection, field_name) do
collection
|> Map.get("fields", [])
|> Enum.any?(fn field -> field["name"] == field_name end)
Typesense.migrate!()
end
end
65 changes: 65 additions & 0 deletions lib/save_it/migration/typesense.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ defmodule SaveIt.Migration.Typesense do

import SaveIt.SmallHelper.UrlHelper, only: [validate_url!: 1]

alias Req.TransportError
alias SaveIt.Migration.Typesense.Photo

def create_collection!(schema) do
req = build_request("/collections")
res = Req.post!(req, json: schema)
Expand All @@ -24,13 +27,75 @@ defmodule SaveIt.Migration.Typesense do
Typesense.handle_response!(res)
end

def migrate! do
typesense_url = Application.fetch_env!(:save_it, :typesense_url)

try do
ensure_photos_collection!()
migrate_photos_if_needed!()

IO.puts("Typesense migration done")
rescue
error in TransportError ->
raise """
Typesense request failed: #{Exception.message(error)}
Current TYPESENSE_URL: #{typesense_url}

If you are using docker-compose locally, try:
export TYPESENSE_URL=http://localhost:8108
docker compose up -d typesense
mix ts.migrate

If you are running a release container, use:
/app/bin/save_it eval 'SaveIt.Release.ts_migrate()'
"""
end
end

def delete_collection(collection_name) do
req = build_request("/collections/#{collection_name}")
res = Req.delete(req)

Typesense.handle_response(res)
end

defp ensure_photos_collection! do
case photos_collection() do
nil ->
IO.puts("creating photos collection")
Photo.create_photos_20241024!()

_collection ->
IO.puts("photos collection already exists, skipping create")
end
end

defp migrate_photos_if_needed! do
case photos_collection() do
nil ->
:ok

collection ->
if has_field?(collection, "url") do
IO.puts("applying photos migration 20241029")
Photo.migrate_photos_20241029!()
else
IO.puts("photos migration already applied, skipping")
end
end
end

defp photos_collection do
list_collections!()
|> Enum.find(fn collection -> collection["name"] == "photos" end)
end

defp has_field?(collection, field_name) do
collection
|> Map.get("fields", [])
|> Enum.any?(fn field -> field["name"] == field_name end)
end

defp get_env() do
url = Application.fetch_env!(:save_it, :typesense_url) |> validate_url!()

Expand Down
19 changes: 19 additions & 0 deletions lib/save_it/release.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
defmodule SaveIt.Release do
@moduledoc false

alias SaveIt.Migration.Typesense

def ts_migrate do
ensure_runtime_dependencies!()
Typesense.migrate!()
end

defp ensure_runtime_dependencies! do
Application.load(:save_it)

case Application.ensure_all_started(:req) do
{:ok, _started} -> :ok
{:error, reason} -> raise "failed to start release migration dependencies: #{inspect(reason)}"
end
end
end
21 changes: 21 additions & 0 deletions others/postmortem/2026-05-24-docker-release-runtime-mix-env.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Docker release runtime Mix.env crash

## What happened

While converting the container image from `mix run --no-halt` to a `mix release`-based image, the Docker image built successfully but failed during a release smoke test.

## Root cause

`config/runtime.exs` used `Mix.env()` for the Sentry `environment_name`. `Mix` is not available in a production release runtime, so the config provider crashed during boot.

## Fix applied

- Replaced `Mix.env()` with `config_env()` in `config/runtime.exs`
- Rebuilt the release and Docker image
- Verified the release binary inside the image with `/app/bin/save_it eval 'IO.puts("release-ok")'`

## What we learned

- A successful `mix release` build is not enough to prove a release boots correctly.
- Any code in `runtime.exs` must avoid `Mix` runtime dependencies.
- A cheap `release eval` smoke test is a good guardrail after Dockerfile or runtime config changes.
Loading