Saltar al contenido
mypipelines
Shared (cross-cutting)·Reusable workflow·on: workflow_call

Shared Deploy Ec2 Vpn Compose

Shared - Deploy Compose Stack to EC2 (WireGuard VPN)

.github/workflows/shared-deploy-ec2-vpn-compose.yml

.github/workflows/shared-deploy-ec2-vpn-compose.yml
name: Shared - Deploy Compose Stack to EC2 (WireGuard VPN)
# Deploys a MULTI-SERVICE docker-compose stack (e.g. the observability LGTM stack)
# to an EC2 host over WireGuard. Unlike shared-deploy-ec2-vpn.yml (single ECR image),
# this syncs the repository (compose file + config dirs) to the host and runs
# `docker compose up -d`. Upstream images are pulled by compose; no ECR build needed.
on:
workflow_call:
inputs:
runner:
description: 'Runner type'
required: false
type: string
default: 'ubuntu-latest'
environment:
description: 'GitHub environment (develop, prod)'
required: false
type: string
default: 'develop'
compose_file:
description: 'Compose file name at the repo root'
required: false
type: string
default: 'docker-compose.yml'
external_networks:
description: 'External docker networks to ensure exist (space-separated)'
required: false
type: string
default: 'codehunters_net'
external_volumes:
description: 'External docker volumes to ensure exist (space-separated)'
required: false
type: string
default: 'shared_logs'
verify_vpn_connectivity:
description: 'Run ping check to EC2 host before deploying'
required: false
type: boolean
default: false
secrets:
AWS_EC2_HOST:
required: true
AWS_EC2_USER:
required: true
AWS_EC2_SSH_KEY:
required: true
WG_PRIVATE_KEY:
required: true
WG_ADDRESS:
required: true
WG_DNS:
required: false
WG_PEER_PUBLIC_KEY:
required: true
WG_PEER_ALLOWED_IPS:
required: true
WG_PEER_ENDPOINT:
required: true
# Optional: contents written to <deploy_dir>/.env before `compose up`
# (e.g. Grafana admin password, OIDC client secret). Left empty = compose defaults.
STACK_ENV_FILE:
required: false
outputs:
deploy_status:
description: 'Deployment status'
value: ${{ jobs.deploy.outputs.status }}
# Least privilege for what this workflow actually does. Without it the
# job inherits whatever the calling repository's default happens to be,
# which on older repositories is write-all.
permissions:
contents: read
jobs:
deploy:
name: Deploy compose stack via WireGuard
runs-on: ${{ inputs.runner }}
environment: ${{ inputs.environment }}
timeout-minutes: 20
outputs:
status: ${{ steps.deploy.outputs.status }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set repository name
id: repo
run: |
REPO_NAME="${{ github.repository }}"
REPO_NAME="${REPO_NAME##*/}"
echo "name=$REPO_NAME" >> $GITHUB_OUTPUT
echo "Repository name: $REPO_NAME"
- name: Install WireGuard
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq wireguard wireguard-tools resolvconf rsync
wg --version
- name: Setup WireGuard tunnel
id: wg
env:
WG_IFACE: wg0
WG_PRIVATE_KEY: ${{ secrets.WG_PRIVATE_KEY }}
WG_ADDRESS: ${{ secrets.WG_ADDRESS }}
WG_DNS: ${{ secrets.WG_DNS }}
WG_PEER_PUBLIC_KEY: ${{ secrets.WG_PEER_PUBLIC_KEY }}
WG_PEER_ALLOWED_IPS: ${{ secrets.WG_PEER_ALLOWED_IPS }}
WG_PEER_ENDPOINT: ${{ secrets.WG_PEER_ENDPOINT }}
run: |
set -e
WG_CONF_PATH="/etc/wireguard/${WG_IFACE}.conf"
sudo mkdir -p /etc/wireguard
{
echo "[Interface]"
echo "PrivateKey = ${WG_PRIVATE_KEY}"
echo "Address = ${WG_ADDRESS}"
if [ -n "${WG_DNS}" ]; then echo "DNS = ${WG_DNS}"; fi
echo ""
echo "[Peer]"
echo "PublicKey = ${WG_PEER_PUBLIC_KEY}"
echo "AllowedIPs = ${WG_PEER_ALLOWED_IPS}"
echo "Endpoint = ${WG_PEER_ENDPOINT}"
echo "PersistentKeepalive = 25"
} | sudo tee "$WG_CONF_PATH" > /dev/null
sudo chmod 600 "$WG_CONF_PATH"
sudo wg-quick up "$WG_IFACE"
sudo wg show "$WG_IFACE"
echo "started=true" >> $GITHUB_OUTPUT
- name: Verify VPN connectivity to EC2
if: inputs.verify_vpn_connectivity
env:
AWS_EC2_HOST: ${{ secrets.AWS_EC2_HOST }}
run: |
for i in 1 2 3 4 5; do
if ping -c 1 -W 3 "${AWS_EC2_HOST}" >/dev/null 2>&1; then
echo "VPN connectivity OK (attempt $i)"; exit 0
fi
echo "Attempt $i failed, retrying..."; sleep 2
done
echo "WARNING: ping failed, will still try SSH"
- name: Deploy compose stack via SSH
id: deploy
env:
AWS_EC2_HOST: ${{ secrets.AWS_EC2_HOST }}
AWS_EC2_USER: ${{ secrets.AWS_EC2_USER }}
AWS_EC2_SSH_KEY: ${{ secrets.AWS_EC2_SSH_KEY }}
STACK_ENV_FILE: ${{ secrets.STACK_ENV_FILE }}
REPO_NAME: ${{ steps.repo.outputs.name }}
COMPOSE_FILE: ${{ inputs.compose_file }}
EXTERNAL_NETWORKS: ${{ inputs.external_networks }}
EXTERNAL_VOLUMES: ${{ inputs.external_volumes }}
run: |
set -Eeo pipefail
mkdir -p ~/.ssh
printf '%s\n' "$AWS_EC2_SSH_KEY" | sed 's/\\n/\n/g' | tr -d '\r' > ~/.ssh/deploy_key
chmod 400 ~/.ssh/deploy_key
ssh-keyscan -H "$AWS_EC2_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
# accept-new: a first-seen host key is accepted, a changed one is refused.
# The WireGuard tunnel authenticates the path, but the STACK_ENV_FILE secret
# still travels over this connection.
SSH_OPTS="-i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=20"
DEPLOY_DIR="/opt/docker/${REPO_NAME}"
echo "Ensuring deploy dir ${DEPLOY_DIR}..."
ssh $SSH_OPTS "${AWS_EC2_USER}@${AWS_EC2_HOST}" \
"sudo mkdir -p '${DEPLOY_DIR}' && sudo chown -R \$(id -un):\$(id -gn) '${DEPLOY_DIR}'"
echo "Syncing repository to ${DEPLOY_DIR}..."
rsync -az --delete \
--exclude '.git' --exclude '.github' --exclude '.env' \
-e "ssh $SSH_OPTS" ./ "${AWS_EC2_USER}@${AWS_EC2_HOST}:${DEPLOY_DIR}/"
# Optional .env (secrets) written on the host, never committed
if [ -n "${STACK_ENV_FILE}" ]; then
echo "Writing ${DEPLOY_DIR}/.env from secret..."
printf '%s' "${STACK_ENV_FILE}" | ssh $SSH_OPTS "${AWS_EC2_USER}@${AWS_EC2_HOST}" \
"cat > '${DEPLOY_DIR}/.env' && chmod 600 '${DEPLOY_DIR}/.env'"
fi
echo "Bringing up stack on host..."
# Unquoted on purpose: DEPLOY_DIR and the network/volume lists are the
# runner's values and must be expanded here; anything meant for the host
# is escaped as \$var below.
# shellcheck disable=SC2087
ssh $SSH_OPTS "${AWS_EC2_USER}@${AWS_EC2_HOST}" bash -s <<REMOTE
set -Eeo pipefail
cd '${DEPLOY_DIR}'
for net in ${EXTERNAL_NETWORKS}; do
docker network inspect "\$net" >/dev/null 2>&1 || docker network create "\$net"
done
for vol in ${EXTERNAL_VOLUMES}; do
docker volume inspect "\$vol" >/dev/null 2>&1 || docker volume create "\$vol"
done
docker compose -p '${REPO_NAME}' -f '${COMPOSE_FILE}' pull --quiet || true
docker compose -p '${REPO_NAME}' -f '${COMPOSE_FILE}' up -d --remove-orphans
docker compose -p '${REPO_NAME}' -f '${COMPOSE_FILE}' ps
REMOTE
echo "Verifying stack..."
RUNNING=$(ssh $SSH_OPTS "${AWS_EC2_USER}@${AWS_EC2_HOST}" \
"cd '${DEPLOY_DIR}' && docker compose -p '${REPO_NAME}' -f '${COMPOSE_FILE}' ps --services --filter status=running | wc -l" || echo 0)
echo "Running services: $RUNNING"
if [ "$RUNNING" -gt 0 ]; then
echo "status=success" >> $GITHUB_OUTPUT
else
echo "::error::No services running after compose up."
echo "status=failure" >> $GITHUB_OUTPUT
exit 1
fi
{
echo "### Compose Stack Deployed (via WireGuard VPN)"
echo ""
echo "| Property | Value |"
echo "|----------|-------|"
echo "| **Server** | ${AWS_EC2_HOST} |"
echo "| **Stack** | ${REPO_NAME} |"
echo "| **Compose** | ${COMPOSE_FILE} |"
echo "| **Services running** | $RUNNING |"
echo "| **Directory** | ${DEPLOY_DIR} |"
} >> $GITHUB_STEP_SUMMARY
- name: Teardown WireGuard tunnel
if: always() && steps.wg.outputs.started == 'true'
env:
WG_IFACE: wg0
run: |
sudo wg-quick down "$WG_IFACE" || true
sudo rm -f "/etc/wireguard/${WG_IFACE}.conf"