diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5d2adff --- /dev/null +++ b/.dockerignore @@ -0,0 +1,46 @@ +# This file excludes paths from the Docker build context. +# +# By default, Docker's build context includes all files (and folders) in the +# current directory. Even if a file isn't copied into the container it is still sent to +# the Docker daemon. +# +# There are multiple reasons to exclude files from the build context: +# +# 1. Prevent nested folders from being copied into the container (ex: exclude +# /assets/node_modules when copying /assets) +# 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc) +# 3. Avoid sending files containing sensitive information +# +# More information on using .dockerignore is available here: +# https://docs.docker.com/engine/reference/builder/#dockerignore-file + +.dockerignore + +# Ignore git, but keep git HEAD and refs to access current commit hash if needed: +# +# $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat +# d0b8727759e1e0e7aa3d41707d12376e373d5ecc +.git +!.git/HEAD +!.git/refs + +# Common development/test artifacts +/cover/ +/doc/ +/test/ +/tmp/ +.elixir_ls + +# Mix artifacts +/_build/ +/deps/ +*.ez + +# Generated on crash by the VM +erl_crash.dump + +# Static artifacts - These should be fetched and built inside the Docker image +# https://hexdocs.pm/phoenix/Mix.Tasks.Phx.Gen.Release.html#module-docker +/assets/node_modules/ +/priv/static/assets/ +/priv/static/cache_manifest.json diff --git a/.github/workflows/fly-deploy.yml b/.github/workflows/fly-deploy.yml new file mode 100644 index 0000000..b0c246e --- /dev/null +++ b/.github/workflows/fly-deploy.yml @@ -0,0 +1,18 @@ +# See https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/ + +name: Fly Deploy +on: + push: + branches: + - main +jobs: + deploy: + name: Deploy app + runs-on: ubuntu-latest + concurrency: deploy-group # optional: ensure only one action runs at a time + steps: + - uses: actions/checkout@v4 + - uses: superfly/flyctl-actions/setup-flyctl@master + - run: flyctl deploy --remote-only + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cd551df --- /dev/null +++ b/Dockerfile @@ -0,0 +1,100 @@ +# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian +# instead of Alpine to avoid DNS resolution issues in production. +# +# https://hub.docker.com/r/hexpm/elixir/tags?name=ubuntu +# https://hub.docker.com/_/ubuntu/tags +# +# This file is based on these images: +# +# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image +# - https://hub.docker.com/_/debian/tags?name=bookworm-20251117-slim - for the release image +# - https://pkgs.org/ - resource for finding needed packages +# - Ex: docker.io/hexpm/elixir:1.19.1-erlang-27.3.4.3-debian-bookworm-20251117-slim +# +ARG ELIXIR_VERSION=1.19.1 +ARG OTP_VERSION=27.3.4.3 +ARG DEBIAN_VERSION=bookworm-20251117-slim + +ARG BUILDER_IMAGE="docker.io/hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" +ARG RUNNER_IMAGE="docker.io/debian:${DEBIAN_VERSION}" + +FROM ${BUILDER_IMAGE} AS builder + +# install build dependencies +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential git nodejs npm \ + && rm -rf /var/lib/apt/lists/* + +# prepare build dir +WORKDIR /app + +# install hex + rebar +RUN mix local.hex --force \ + && mix local.rebar --force + +# set build ENV +ENV MIX_ENV="prod" + +# install mix dependencies +COPY mix.exs mix.lock ./ +RUN mix deps.get --only $MIX_ENV +RUN mkdir config + +# copy compile-time config files before we compile dependencies +# to ensure any relevant config change will trigger the dependencies +# to be re-compiled. +COPY config/config.exs config/${MIX_ENV}.exs config/ +RUN mix deps.compile + +COPY priv priv + +COPY lib lib + +COPY assets assets + +RUN mix setup + +RUN mix compile + +RUN mix assets.deploy + +# Changes to config/runtime.exs don't require recompiling the code +COPY config/runtime.exs config/ + +COPY rel rel +RUN mix release + +# start a new build stage so that the final image will only contain +# the compiled release and other runtime necessities +FROM ${RUNNER_IMAGE} AS final + +RUN apt-get update \ + && apt-get install -y --no-install-recommends inotify-tools libstdc++6 openssl libncurses5 locales ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Set the locale +RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen \ + && locale-gen + +ENV LANG=en_US.UTF-8 +ENV LANGUAGE=en_US:en +ENV LC_ALL=en_US.UTF-8 + +WORKDIR "/app" +RUN chown nobody /app + +# set runner ENV +ENV MIX_ENV="prod" +ENV PHX_SERVER="true" + +# Only copy the final release from the build stage +COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/alchemy_pub ./ + +USER nobody + +# If using an environment that doesn't automatically reap zombie processes, it is +# advised to add an init process such as tini via `apt-get install` +# above and adding an entrypoint. See https://github.com/krallin/tini for details +# ENTRYPOINT ["/tini", "--"] + +CMD ["/bin/sh", "-c", "/app/bin/migrate && exec /app/bin/server"] diff --git a/README.md b/README.md index 085e528..c7e99f4 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,18 @@ mix phx.digest.clean --all PHX_SERVER=true PHX_HOST=localhost SECRET_KEY_BASE="{{ key }}" _build/prod/rel/alchemy_pub/bin/alchemy_pub start ``` +### Deployment on fly.io + +Set an admin secret +``` +flyctl secrets set ADMIN_SECRET=(openssl rand -base64 48) +``` + +And deploy +``` +flyctl deploy +``` + ## Credits AlchemyPub is originally brought to you by [j4nk.dev](https://j4nk.dev). It is published under the [Apache License 2.0](LICENSE). diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..2a2c20b --- /dev/null +++ b/fly.toml @@ -0,0 +1,33 @@ +# fly.toml app configuration file generated for alchemy-pub-crimson-thunder-7663 on 2025-11-18T19:12:18+01:00 +# +# See https://fly.io/docs/reference/configuration/ for information about how to use this file. +# + +app = 'alchemy-pub' +primary_region = 'fra' +kill_signal = 'SIGTERM' + +[build] + +[env] + DATABASE_PATH = '/app/tracker.db' + PHX_HOST = 'alchemy-pub.fly.dev' + PORT = '8080' + +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = 'stop' + auto_start_machines = true + min_machines_running = 0 + processes = ['app'] + + [http_service.concurrency] + type = 'connections' + hard_limit = 1000 + soft_limit = 1000 + +[[vm]] + memory = '1gb' + cpu_kind = 'shared' + cpus = 1 diff --git a/lib/alchemy_pub/application.ex b/lib/alchemy_pub/application.ex index 7bd33e1..defa5d9 100644 --- a/lib/alchemy_pub/application.ex +++ b/lib/alchemy_pub/application.ex @@ -17,7 +17,7 @@ defmodule AlchemyPub.Application do # {AlchemyPub.Worker, arg}, {Registry, [keys: :unique, name: AlchemyPub.Registry]}, {DynamicSupervisor, strategy: :one_for_one, name: AlchemyPub.DeckSupervisor}, - {AlchemyPub.Engine, base_path: "priv/pages"}, + {AlchemyPub.Engine, base_path: Path.join(:code.priv_dir(:alchemy_pub), "pages")}, AlchemyPub.Presence, AlchemyPub.Repo, # Start to serve requests, typically the last entry diff --git a/lib/alchemy_pub/engine.ex b/lib/alchemy_pub/engine.ex index 462b155..3ca94c7 100644 --- a/lib/alchemy_pub/engine.ex +++ b/lib/alchemy_pub/engine.ex @@ -216,11 +216,13 @@ defmodule AlchemyPub.Engine do end def init(base_path: path) do - {:ok, watcher_pid} = FileSystem.start_link(dirs: [path], name: :file_watcher) + # Ensure path is absolute + abs_path = Path.expand(path) + {:ok, watcher_pid} = FileSystem.start_link(dirs: [abs_path], name: :file_watcher) FileSystem.subscribe(watcher_pid) :ets.new(@ets, [:set, :named_table]) - files = Path.wildcard("#{path}/**/*.md") + files = Path.wildcard("#{abs_path}/**/*.md") entries = for f <- files, path = Path.expand(f) do diff --git a/lib/alchemy_pub/release.ex b/lib/alchemy_pub/release.ex new file mode 100644 index 0000000..0723baf --- /dev/null +++ b/lib/alchemy_pub/release.ex @@ -0,0 +1,30 @@ +defmodule AlchemyPub.Release do + @moduledoc """ + Used for executing DB release tasks when run in production without Mix + installed. + """ + @app :alchemy_pub + + 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 + # Many platforms require SSL when connecting to the database + Application.ensure_all_started(:ssl) + Application.ensure_loaded(@app) + end +end diff --git a/lib/alchemy_pub_web/controllers/error_html.ex b/lib/alchemy_pub_web/controllers/error_html.ex new file mode 100644 index 0000000..85e69b5 --- /dev/null +++ b/lib/alchemy_pub_web/controllers/error_html.ex @@ -0,0 +1,19 @@ +defmodule AlchemyPubWeb.ErrorHTML do + use AlchemyPubWeb, :html + + # If you want to customize your error pages, + # uncomment the embed_templates/1 call below + # and add pages to the error directory: + # + # * lib/alchemy_pub_web/controllers/error_html/404.html.heex + # * lib/alchemy_pub_web/controllers/error_html/500.html.heex + # + # embed_templates "error_html/*" + + # The default is to render a plain text page based on + # the template name. For example, "404.html" becomes + # "Not Found". + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/lib/alchemy_pub_web/controllers/error_json.ex b/lib/alchemy_pub_web/controllers/error_json.ex new file mode 100644 index 0000000..158fddd --- /dev/null +++ b/lib/alchemy_pub_web/controllers/error_json.ex @@ -0,0 +1,15 @@ +defmodule AlchemyPubWeb.ErrorJSON do + # If you want to customize a particular status code, + # you may add your own clauses, such as: + # + # def render("500.json", _assigns) do + # %{errors: %{detail: "Internal Server Error"}} + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.json" becomes + # "Not Found". + def render(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/lib/alchemy_pub_web/router.ex b/lib/alchemy_pub_web/router.ex index 432c008..15ebe33 100644 --- a/lib/alchemy_pub_web/router.ex +++ b/lib/alchemy_pub_web/router.ex @@ -34,8 +34,8 @@ defmodule AlchemyPubWeb.Router do # end def require_basic_auth(conn, _opts) do - username = System.get_env("AUTH_USERNAME") - password = System.get_env("AUTH_PASSWORD") + username = System.get_env("AUTH_USERNAME") || "admin" + password = System.get_env("AUTH_PASSWORD") || Application.get_env(:alchemy_pub, :admin_secret) Plug.BasicAuth.basic_auth(conn, username: username, password: password) end diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..be35144 --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +flyctl = "latest" diff --git a/rel/env.sh.eex b/rel/env.sh.eex new file mode 100755 index 0000000..f896ca0 --- /dev/null +++ b/rel/env.sh.eex @@ -0,0 +1,19 @@ +#!/bin/sh + +if [ -n "$FLY_APP_NAME" ]; then + export DNS_CLUSTER_QUERY="${FLY_APP_NAME}.internal" + export RELEASE_NODE="${FLY_APP_NAME}-${FLY_IMAGE_REF##*-}@${FLY_PRIVATE_IP}" + # configure node for distributed erlang with IPV6 support + export ERL_AFLAGS="-proto_dist inet6_tcp" + export ECTO_IPV6="true" +fi + +export RELEASE_DISTRIBUTION="name" + +# Uncomment to send crash dumps to stderr +# This can be useful for debugging, but may log sensitive information +# export ERL_CRASH_DUMP=/dev/stderr +# export ERL_CRASH_DUMP_BYTES=4096 + +# when not running on fly.io, use a sensible default +export RELEASE_NODE=${RELEASE_NODE:-<%= @release.name %>@$(hostname)} diff --git a/rel/overlays/bin/migrate b/rel/overlays/bin/migrate new file mode 100755 index 0000000..e8b3d8d --- /dev/null +++ b/rel/overlays/bin/migrate @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +cd -P -- "$(dirname -- "$0")" +exec ./alchemy_pub eval AlchemyPub.Release.migrate diff --git a/rel/overlays/bin/migrate.bat b/rel/overlays/bin/migrate.bat new file mode 100755 index 0000000..3e4ae87 --- /dev/null +++ b/rel/overlays/bin/migrate.bat @@ -0,0 +1 @@ +call "%~dp0\alchemy_pub" eval AlchemyPub.Release.migrate diff --git a/rel/overlays/bin/server b/rel/overlays/bin/server new file mode 100755 index 0000000..5cb6dd0 --- /dev/null +++ b/rel/overlays/bin/server @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu + +cd -P -- "$(dirname -- "$0")" +PHX_SERVER=true exec ./alchemy_pub start diff --git a/rel/overlays/bin/server.bat b/rel/overlays/bin/server.bat new file mode 100755 index 0000000..95ec160 --- /dev/null +++ b/rel/overlays/bin/server.bat @@ -0,0 +1,2 @@ +set PHX_SERVER=true +call "%~dp0\alchemy_pub" start