10 changed files with 416 additions and 1 deletions
@ -0,0 +1,46 @@
@@ -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 |
||||
@ -0,0 +1,14 @@
@@ -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 |
||||
@ -0,0 +1,95 @@
@@ -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"] |
||||
@ -0,0 +1,40 @@
@@ -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 |
||||
@ -0,0 +1,175 @@
@@ -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 <repo-url> /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. |
||||
@ -0,0 +1,30 @@
@@ -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 |
||||
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
defmodule GcIndexRelayWeb.HealthController do |
||||
use GcIndexRelayWeb, :controller |
||||
|
||||
def check(conn, _params) do |
||||
send_resp(conn, 200, "ok") |
||||
end |
||||
end |
||||
Loading…
Reference in new issue