Created
November 14, 2025 20:54
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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