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/.env.example b/.env.example new file mode 100644 index 0000000..3d717ee --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Database credentials (used by both db and app containers) +POSTGRES_USER=gc_relay +POSTGRES_PASSWORD=CHANGE_ME_GENERATE_A_STRONG_PASSWORD +POSTGRES_DB=gc_index_relay_prod + +# Phoenix secrets +# Generate with: mix phx.gen.secret +SECRET_KEY_BASE=CHANGE_ME_GENERATE_WITH_MIX_PHX_GEN_SECRET + +# Public hostname (used in URL generation) +PHX_HOST=example.com + +# HTTP port (default 4000) +PORT=4000 diff --git a/.gitignore b/.gitignore index 220aa05..10c0884 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,9 @@ gc_index_relay-*.tar # Language servers .elixir_ls/ .expert/ + +# Secrets -- copy from .env.example and fill in values +.env + +# Release artifacts -- regenerate these per-release +/rel/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c67326c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,95 @@ +# 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=trixie-20260202-slim - for the release image +# - https://pkgs.org/ - resource for finding needed packages +# - Ex: docker.io/hexpm/elixir:1.17.3-erlang-27.3.4.7-debian-trixie-20260202-slim +# +ARG ELIXIR_VERSION=1.17.3 +ARG OTP_VERSION=27.3.4.7 +ARG DEBIAN_VERSION=trixie-20260202-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 cmake \ + && 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 +# TODO: Vary this by deployed environment via passed-in env var +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 + +# Compile the release +RUN mix compile + +# 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 libstdc++6 openssl libncurses6 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" + +# Only copy the final release from the build stage +COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/gc_index_relay ./ + +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 ["/app/bin/server"] diff --git a/config/prod.exs b/config/prod.exs index 8e61144..45d1bfb 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -6,7 +6,7 @@ import Config config :gc_index_relay, GcIndexRelayWeb.Endpoint, force_ssl: [rewrite_on: [:x_forwarded_proto]], exclude: [ - # paths: ["/health"], + paths: ["/health"], hosts: ["localhost", "127.0.0.1"] ] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..669d78e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +services: + db: + build: ./db + volumes: + - pgdata:/var/lib/postgresql/data + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + networks: + - internal + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + interval: 5s + timeout: 5s + retries: 5 + + app: + build: . + ports: + - "${PORT:-4000}:${PORT:-4000}" + environment: + DATABASE_URL: "ecto://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db/${POSTGRES_DB}" + SECRET_KEY_BASE: ${SECRET_KEY_BASE} + PHX_HOST: ${PHX_HOST} + PORT: ${PORT:-4000} + depends_on: + db: + condition: service_healthy + networks: + - internal + restart: unless-stopped + +volumes: + pgdata: + +networks: + internal: + driver: bridge diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..3960dd2 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,175 @@ +# Deployment Guide + +This guide covers deploying GcIndexRelay on a VPS using Docker Compose. + +## Architecture + +``` +Host reverse proxy (port 80/443) → localhost:4000 + ↓ +Phoenix container (app) — published port 4000:4000 + ↓ (internal Docker network only) +AGE/Postgres container (db) — no published ports, volume: pgdata +``` + +TLS is terminated by the host-level reverse proxy. The Phoenix app runs behind +it and trusts the `X-Forwarded-Proto` header to enforce HTTPS. + +## Prerequisites + +- A VPS running Debian/Ubuntu +- Docker and Docker Compose installed +- A domain name pointed at your VPS + +## 1. Install Docker and Docker Compose + +```bash +curl -fsSL https://get.docker.com | sh +sudo usermod -aG docker $USER +# Log out and back in for group membership to take effect +``` + +Verify: `docker compose version` + +## 2. Clone the Repository + +```bash +git clone /opt/gc_index_relay +cd /opt/gc_index_relay +``` + +## 3. Configure Secrets + +Copy the example file and fill in your values: + +```bash +cp .env.example .env +$EDITOR .env +``` + +Required values to set in `.env`: + +- **`POSTGRES_PASSWORD`** — use a strong random password +- **`SECRET_KEY_BASE`** — generate with: + ```bash + docker run --rm hexpm/elixir:1.17.3-erlang-27.3.4.7-debian-trixie-20260202-slim \ + mix phx.gen.secret + # or without Docker: + openssl rand -base64 64 + ``` +- **`PHX_HOST`** — your actual domain name (e.g. `relay.example.com`) + +## 4. Set Up the Reverse Proxy + +The app listens on `localhost:4000`. Configure nginx or Caddy to forward +traffic and terminate TLS. + +### Caddy (recommended) + +```caddy +relay.example.com { + reverse_proxy localhost:4000 +} +``` + +### nginx + +```nginx +server { + listen 443 ssl; + server_name relay.example.com; + + ssl_certificate /etc/letsencrypt/live/relay.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/relay.example.com/privkey.pem; + + location / { + proxy_pass http://localhost:4000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +## 5. Configure Firewall + +Expose only SSH, HTTP, and HTTPS to the public internet: + +```bash +sudo ufw allow 22/tcp +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw enable +``` + +The database port (5432) must **not** be exposed — it is on an internal Docker +network only and never published to the host. + +## 6. Build and Start the Stack + +```bash +docker compose build +docker compose up -d +``` + +On first start, the app container will: +1. Wait for the database to pass its health check +2. Run any pending Ecto migrations +3. Start the Phoenix server + +## 7. Verify the Deployment + +```bash +# Check container status +docker compose ps + +# Health check endpoint +curl http://localhost:4000/health +# Expected: 200 OK, body: "ok" + +# API endpoint +curl http://localhost:4000/api/events +# Expected: JSON response + +# Confirm database port is NOT accessible from host +curl http://localhost:5432 +# Expected: connection refused +``` + +## Operations + +### View logs + +```bash +docker compose logs -f app +docker compose logs -f db +``` + +### Restart the app + +```bash +docker compose restart app +``` + +### Stop everything + +```bash +docker compose down +``` + +Database data is persisted in the `pgdata` Docker volume and survives +`docker compose down`. To also delete the data: + +```bash +docker compose down -v +``` + +### Update to a new version + +```bash +git pull +docker compose build +docker compose up -d +``` + +Migrations run automatically on startup. diff --git a/lib/gc_index_relay/release.ex b/lib/gc_index_relay/release.ex new file mode 100644 index 0000000..ef72b8c --- /dev/null +++ b/lib/gc_index_relay/release.ex @@ -0,0 +1,30 @@ +defmodule GcIndexRelay.Release do + @moduledoc """ + Used for executing DB release tasks when run in production without Mix + installed. + """ + @app :gc_index_relay + + 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/gc_index_relay_web/controllers/health_controller.ex b/lib/gc_index_relay_web/controllers/health_controller.ex new file mode 100644 index 0000000..9dd7f00 --- /dev/null +++ b/lib/gc_index_relay_web/controllers/health_controller.ex @@ -0,0 +1,7 @@ +defmodule GcIndexRelayWeb.HealthController do + use GcIndexRelayWeb, :controller + + def check(conn, _params) do + send_resp(conn, 200, "ok") + end +end diff --git a/lib/gc_index_relay_web/router.ex b/lib/gc_index_relay_web/router.ex index 0a3f810..96e4e80 100644 --- a/lib/gc_index_relay_web/router.ex +++ b/lib/gc_index_relay_web/router.ex @@ -20,6 +20,8 @@ defmodule GcIndexRelayWeb.Router do get "/", PageController, :home end + get "/health", GcIndexRelayWeb.HealthController, :check + scope "/api", GcIndexRelayWeb do pipe_through :api