Skip to content

Instantly share code, notes, and snippets.

@Tombarr
Created November 14, 2025 20:54
Show Gist options
  • Select an option

  • Save Tombarr/17b94f79ccfc698792a2abafb8955409 to your computer and use it in GitHub Desktop.

Select an option

Save Tombarr/17b94f79ccfc698792a2abafb8955409 to your computer and use it in GitHub Desktop.
GitHub Action to build and deploy an Elixir/ Phoenix web app on AWS EC2 running Amazon Linux 2023
name: Build & Deploy Phoenix (linux/x86) to EC2
on:
push:
branches: [ "main" ]
workflow_dispatch:
env:
MIX_ENV: prod
ELIXIR_VERSION: "1.18.4" # adjust as needed
OTP_VERSION: "27.3.4.2" # match what you have in prod
BASE_OTP_VERSION: "27" # match what you have in prod
APP_NAME: "myapp" # lower_snake_case as created by mix new
PHX_PORT: "4000"
PHX_HOST: "example.com"
jobs:
build:
permissions:
contents: read
packages: write
name: Build linux/x86 release
runs-on: ubuntu-latest
environment: prod
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
buildkitd-flags: --debug
install: true
driver-opts: |
image=moby/buildkit:buildx-stable-1
network=host
- name: Create optimized Dockerfile
run: |
cat <<EOF > Dockerfile.build
FROM amazonlinux:2023 AS base
# Install build tools and dependencies (cached layer)
RUN dnf update -y && \
dnf install -y \
git tar gzip gcc gcc-c++ make wget \
openssl-devel ncurses-devel libxslt libxml2 \
openssl-static ncurses-static zlib-static \
unzip libtool \
--skip-broken && \
dnf clean all
# Download stage - separate for early failure and caching
FROM base AS downloader
ARG OTP_VERSION
ARG ELIXIR_VERSION
ARG BASE_OTP_VERSION
# Download files in parallel where possible
RUN echo "Downloading OTP \${OTP_VERSION} and Elixir \${ELIXIR_VERSION}..." && \
wget -q --spider "https://github.com/erlang/otp/releases/download/OTP-\${OTP_VERSION}/otp_src_\${OTP_VERSION}.tar.gz" && \
wget -q --spider "https://github.com/elixir-lang/elixir/releases/download/v\${ELIXIR_VERSION}/elixir-otp-\${BASE_OTP_VERSION}.zip" && \
wget -q "https://github.com/erlang/otp/releases/download/OTP-\${OTP_VERSION}/otp_src_\${OTP_VERSION}.tar.gz" -O /tmp/otp_src.tar.gz & \
wget -q "https://github.com/elixir-lang/elixir/releases/download/v\${ELIXIR_VERSION}/elixir-otp-\${BASE_OTP_VERSION}.zip" -O /tmp/elixir-precompiled.zip & \
wait
# OTP build stage - heavily optimized
FROM downloader AS otp-builder
COPY --from=downloader /tmp/otp_src.tar.gz /tmp/
RUN echo "Building OTP with optimizations..." && \
tar -xzf /tmp/otp_src.tar.gz -C /tmp && \
cd /tmp/otp_src_* && \
./configure \
--disable-jit \
--disable-hipe \
--disable-sctp \
--disable-silent-rules \
--enable-shared-zlib \
--enable-threads \
--with-ssl \
--without-javac \
--without-odbc \
--without-wx && \
make -j\$(nproc) && \
make install && \
rm -rf /tmp/*
# Final runtime stage
FROM base AS runtime
# Copy OTP from builder stage
COPY --from=otp-builder /usr/local /usr/local
# Install Elixir from precompiled binary
COPY --from=downloader /tmp/elixir-precompiled.zip /tmp/
RUN unzip -q /tmp/elixir-precompiled.zip -d /usr/local/elixir && \
ln -sf /usr/local/elixir/bin/elixir /usr/local/bin/elixir && \
ln -sf /usr/local/elixir/bin/mix /usr/local/bin/mix && \
ln -sf /usr/local/elixir/bin/iex /usr/local/bin/iex && \
rm -f /tmp/elixir-precompiled.zip
# Verify installations and pre-install common tools
RUN erl -version && elixir --version && \
mix local.hex --force && \
mix local.rebar --force
# Set up working directory
WORKDIR /app
EOF
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
logout: false
- name: Build optimized container with caching
run: |
REPO_LC=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]') && \
docker buildx build \
--build-arg OTP_VERSION=${{ env.OTP_VERSION }} \
--build-arg ELIXIR_VERSION=${{ env.ELIXIR_VERSION }} \
--build-arg BASE_OTP_VERSION=${{ env.BASE_OTP_VERSION }} \
--cache-from="type=registry,ref=ghcr.io/${REPO_LC}/builder-cache:latest" \
--cache-to="type=registry,ref=ghcr.io/${REPO_LC}/builder-cache:latest,mode=max" \
--load \
-t amazon-linux-builder \
-f Dockerfile.build .
- name: Create artifact directory
run: mkdir -p release_out
- name: Build Phoenix Release with optimizations
run: |
docker run --rm \
-v ${{ github.workspace }}:/app \
-v $PWD/release_out:/app/release_out \
-e MIX_ENV=${{ env.MIX_ENV }} \
-e SECRET_KEY_BASE=${{ secrets.SECRET_KEY_BASE }} \
-e RELEASE_COOKIE=${{ secrets.RELEASE_COOKIE }} \
-e ELIXIR_ERL_OPTIONS="+fnu" \
--workdir /app \
amazon-linux-builder \
/bin/bash -c "
set -euo pipefail
# Ensure release_out directory exists inside container
mkdir -p /app/release_out
echo 'Getting dependencies...'
mix deps.get --only \${MIX_ENV}
echo 'Compiling with optimizations...'
# Compile with multiple jobs and optimizations
MIX_ENV=\${MIX_ENV} mix compile --force --all-warnings
echo 'Building release...'
MIX_ENV=\${MIX_ENV} mix release --overwrite
echo 'Packaging artifact...'
tar -C _build/\${MIX_ENV}/rel/${{ env.APP_NAME }} -czf /app/release_out/${{ env.APP_NAME }}.tar.gz .
echo 'Build complete!'
"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ env.APP_NAME }}-x86-release
path: release_out/${{ env.APP_NAME }}.tar.gz
retention-days: 7
outputs:
app_name: ${{ env.APP_NAME }}
deploy:
name: Deploy to EC2
runs-on: ubuntu-latest
needs: build
environment: prod
if: github.ref == 'refs/heads/main'
env:
APP_NAME: "${{ needs.build.outputs.app_name }}"
REMOTE_DIR: "/opt/${{ needs.build.outputs.app_name }}"
SERVICE_NAME: "${{ needs.build.outputs.app_name }}"
ENV_FILE: "/etc/${{ needs.build.outputs.app_name }}.env"
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: ${{ env.APP_NAME }}-x86-release
path: .
- name: Copy release to EC2
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USER }}
key: ${{ secrets.EC2_SSH_KEY }}
source: "${{ env.APP_NAME }}.tar.gz"
target: "/tmp/"
debug: true
- name: Configure & restart service on EC2
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USER }}
key: ${{ secrets.EC2_SSH_KEY }}
script: |
set -euo pipefail
APP_NAME="${{ env.APP_NAME }}"
REMOTE_DIR="${{ env.REMOTE_DIR }}"
SERVICE_NAME="${{ env.SERVICE_NAME }}"
ENV_FILE="${{ env.ENV_FILE }}"
TARBALL="/tmp/${APP_NAME}.tar.gz"
# Create app directory and user if needed
if ! id -u "$APP_NAME" >/dev/null 2>&1; then
sudo useradd -r -s /bin/false -M "$APP_NAME"
fi
sudo mkdir -p "$REMOTE_DIR"
sudo chown -R "$APP_NAME":"$APP_NAME" "$REMOTE_DIR"
# Write/refresh environment file (runtime config)
sudo bash -c "cat > $ENV_FILE" << 'EOF'
MIX_ENV=prod
PHX_SERVER=true
PORT=${{ env.PHX_PORT }}
PHX_HOST=${{ env.PHX_HOST }}
SECRET_KEY_BASE=${SECRET_KEY_BASE}
RELEASE_COOKIE=${RELEASE_COOKIE}
${{ secrets.ENV_VARS }}
EOF
# Substitute secrets into env file
sudo sed -i "s|\${SECRET_KEY_BASE}|${{ secrets.SECRET_KEY_BASE }}|g" "$ENV_FILE"
sudo sed -i "s|\${RELEASE_COOKIE}|${{ secrets.RELEASE_COOKIE }}|g" "$ENV_FILE"
sudo sed -i "s|\${PHX_HOST}|${{ env.PHX_HOST || 'localhost' }}|g" "$ENV_FILE"
sudo sed -i "s|\${PORT}|${{ env.PHX_PORT || '80' }}|g" "$ENV_FILE"
# Lock down environment file
sudo chown root:root "$ENV_FILE"
sudo chmod 600 "$ENV_FILE"
# Install or refresh systemd service
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
sudo bash -c "cat > $SERVICE_FILE" << EOF
[Unit]
Description=${APP_NAME} Phoenix app
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=${APP_NAME}
Group=${APP_NAME}
EnvironmentFile=${ENV_FILE}
WorkingDirectory=${REMOTE_DIR}
ExecStart=${REMOTE_DIR}/bin/${APP_NAME} start
ExecStop=${REMOTE_DIR}/bin/${APP_NAME} stop
Restart=always
RestartSec=5
PrivateTmp=true
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable "${SERVICE_NAME}"
# Deploy new release
TMP_DIR="$(mktemp -d)"
sudo tar -C "$TMP_DIR" -xzf "$TARBALL"
if [ -d "${REMOTE_DIR}/var" ]; then
sudo rsync -a "${REMOTE_DIR}/var/" "${TMP_DIR}/var/" || true
fi
sudo rsync -a --delete "${TMP_DIR}/" "${REMOTE_DIR}/"
sudo chown -R ${APP_NAME}:${APP_NAME} "${REMOTE_DIR}"
# Clean old logs for a fresh journal view
sudo systemctl stop "${SERVICE_NAME}"
sudo journalctl --rotate
sudo journalctl --vacuum-time=1s
# Restart service with clean logs
sudo systemctl start "${SERVICE_NAME}"
sudo systemctl status "${SERVICE_NAME}" --no-pager -l || true
echo "Deployment complete."
# Log service status directly
sudo journalctl -u "${SERVICE_NAME}" -n 20 --since "10 second ago"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment