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

Shared Deploy Ec2 Vpn

Shared - Deploy to EC2 (WireGuard VPN)

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

.github/workflows/shared-deploy-ec2-vpn.yml
name: Shared - Deploy to EC2 (WireGuard VPN)
on:
workflow_call:
inputs:
runner:
description: 'Runner type'
required: false
type: string
default: 'ubuntu-latest'
image_tag:
description: 'Docker image tag to deploy'
required: true
type: string
image_digest:
description: >-
Digest to deploy (sha256:…). When set, the deploy pins the image by
digest and the tag is only a label. Empty deploys by tag, as Helm
deploys do and as any direct caller that passes no digest does.
required: false
type: string
default: ''
environment:
description: 'GitHub environment (develop, prod)'
required: false
type: string
default: 'develop'
verify_vpn_connectivity:
description: 'Run ping check to EC2 host before deploying'
required: false
type: boolean
default: false
docker_platform:
description: 'Docker platform for the container (linux/amd64, linux/arm64)'
required: false
type: string
default: 'linux/arm64'
memory_limit:
description: 'Container memory limit (e.g., 512m, 1g)'
required: false
type: string
default: '800m'
memory_reservation:
description: 'Container memory reservation (e.g., 256m, 512m)'
required: false
type: string
default: '256m'
internal_port:
description: 'Container internal port exposed by the app (8080 for Java/Krakend, 8545 for Hardhat, 3000 for React, etc.)'
required: false
type: string
default: '8080'
container_env_vars:
description: 'Additional environment variables block for docker-compose (one per line: KEY=VALUE)'
required: false
type: string
default: ''
extra_volumes:
description: 'Additional docker-compose volume mounts (one per line, including the leading "- ", e.g. "- ./certs:/etc/krakend/certs:ro")'
required: false
type: string
default: ''
secrets:
AWS_ECR_URL:
required: true
AWS_ACCESS_KEY_ID:
required: true
AWS_SECRET_ACCESS_KEY:
required: true
AWS_REGION:
required: true
AWS_EC2_HOST:
required: true
AWS_EC2_USER:
required: true
AWS_EC2_SSH_KEY:
required: true
AWS_APP_PORT:
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
outputs:
deploy_status:
description: 'Deployment status'
value: ${{ jobs.deploy.outputs.status }}
deployed_image:
description: 'Deployed image tag'
value: ${{ jobs.deploy.outputs.image }}
# 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 to EC2 via WireGuard
runs-on: ${{ inputs.runner }}
environment: ${{ inputs.environment }}
timeout-minutes: 20
outputs:
status: ${{ steps.deploy.outputs.status }}
image: ${{ inputs.image_tag }}
steps:
- 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
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"
echo "Writing WireGuard config to ${WG_CONF_PATH}"
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"
echo "Bringing up WireGuard interface ${WG_IFACE}..."
sudo wg-quick up "$WG_IFACE"
echo "WireGuard status:"
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: |
echo "Testing connectivity to ${AWS_EC2_HOST} over VPN..."
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 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 }}
AWS_ECR_URL: ${{ secrets.AWS_ECR_URL }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: ${{ secrets.AWS_REGION }}
IMAGE_TAG: ${{ inputs.image_tag }}
IMAGE_DIGEST: ${{ inputs.image_digest }}
APP_PORT: ${{ secrets.AWS_APP_PORT }}
REPO_NAME: ${{ steps.repo.outputs.name }}
ENVIRONMENT: ${{ inputs.environment }}
DOCKER_PLATFORM: ${{ inputs.docker_platform }}
MEMORY_LIMIT: ${{ inputs.memory_limit }}
MEMORY_RESERVATION: ${{ inputs.memory_reservation }}
INTERNAL_PORT: ${{ inputs.internal_port }}
CONTAINER_ENV_VARS: ${{ inputs.container_env_vars }}
EXTRA_VOLUMES: ${{ inputs.extra_volumes }}
run: |
# Computed on the runner and shipped in deploy.env: the compose file and
# the pull live in deploy.sh, which runs on the host and sees only that file.
IMAGE_REPO="${AWS_ECR_URL}/${REPO_NAME}"
IMAGE_REF="${IMAGE_REPO}:${IMAGE_TAG}"
if [ -n "${IMAGE_DIGEST}" ]; then IMAGE_REF="${IMAGE_REPO}@${IMAGE_DIGEST}"; fi
# Setup SSH
mkdir -p ~/.ssh
# Write key: sed converts literal \n (from secrets stored as one-liners) to real newlines;
# tr strips \r to guard against Windows-style line endings.
printf '%s\n' "$AWS_EC2_SSH_KEY" | sed 's/\\n/\n/g' | tr -d '\r' > ~/.ssh/deploy_key
chmod 400 ~/.ssh/deploy_key
# '|| true': ssh-keyscan returns nonzero when the host is unreachable; under `bash -e`
# that would abort the step silently (stderr hidden) before the scp retry/skip logic runs.
# known_hosts is seeded here and then enforced: the connection below uses
# StrictHostKeyChecking=accept-new, which accepts a host seen for the first
# time but refuses a key that CHANGED. Under the old `no` a contradicting
# key was accepted too, which made this keyscan decorative.
ssh-keyscan -H $AWS_EC2_HOST >> ~/.ssh/known_hosts 2>/dev/null || true
# Encode multiline env vars to preserve newlines over SSH
CONTAINER_ENV_VARS_B64=$(echo "$CONTAINER_ENV_VARS" | base64 | tr -d '\n')
EXTRA_VOLUMES_B64=$(echo "$EXTRA_VOLUMES" | base64 | tr -d '\n')
# Create deployment script
cat > /tmp/deploy.sh << 'DEPLOY_SCRIPT'
#!/bin/bash
set -Eeo pipefail
trap 'echo "::error::deploy.sh failed at line ${LINENO}: ${BASH_COMMAND} (exit ${?})"' ERR
DEPLOY_DIR="/opt/docker/${REPO_NAME}"
echo "=========================================="
echo "Deploying ${REPO_NAME}"
echo "Image tag: ${IMAGE_TAG}"
echo "Environment: ${ENVIRONMENT}"
echo "=========================================="
# Ensure deploy dir exists AND is owned by the deploy user — EVERY run, not just on
# creation. A pre-existing dir left by root/another user (e.g. a copied AMI) would make
# 'start.sh'/'stop.sh' writes fail with "Permission denied".
echo "Ensuring deploy dir ownership: $DEPLOY_DIR"
sudo mkdir -p "$DEPLOY_DIR"
sudo chown -R "$(id -un):$(id -gn)" "$DEPLOY_DIR"
cd "$DEPLOY_DIR"
# Create shared_logs volume if not exists
docker volume inspect shared_logs >/dev/null 2>&1 || docker volume create shared_logs
# Login to ECR
echo "Logging in to ECR..."
aws ecr get-login-password --region ${AWS_REGION} | docker login --username AWS --password-stdin ${AWS_ECR_URL}
# The keys did their job: docker pull uses the login above, not these. Unset them so
# a container_env_vars line that names one (or interpolates it) cannot pass the
# build-plane keys, which can push to ECR, into the application container.
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
# Decode env vars from base64
CONTAINER_ENV_VARS=$(echo "${CONTAINER_ENV_VARS_B64}" | base64 -d)
EXTRA_VOLUMES=$(echo "${EXTRA_VOLUMES_B64}" | base64 -d)
# The container never gets the pipeline's AWS keys: they can push to ECR, and
# the application reads its own credentials from the instance profile. A host
# last deployed by a v1 caller may still hold the .aws.env that v1 wrote with
# those keys, so it is removed here rather than left on disk.
rm -f .aws.env
# Create docker-compose.yml (standard name)
COMPOSE_FILE_NAME="docker-compose.yml"
echo "Creating ${COMPOSE_FILE_NAME}..."
{
echo "services:"
echo " ${REPO_NAME}:"
echo " image: ${IMAGE_REF}"
echo " platform: ${DOCKER_PLATFORM}"
echo " container_name: ${REPO_NAME}"
echo " restart: unless-stopped"
echo " environment:"
echo " - APP_NAME=${REPO_NAME}"
echo " - PATH_LOGS=/app/log/${REPO_NAME}"
echo " - AWS_REGION=${AWS_REGION}"
if [ -n "${CONTAINER_ENV_VARS}" ]; then
while IFS= read -r line; do
if [ -n "$line" ]; then
echo " - ${line}"
fi
done <<< "${CONTAINER_ENV_VARS}"
fi
echo " ports:"
echo " - \"${APP_PORT}:${INTERNAL_PORT}\""
echo " volumes:"
echo " - shared_logs:/app/log"
if [ -n "${EXTRA_VOLUMES}" ]; then
while IFS= read -r line; do
if [ -n "$line" ]; then
echo " ${line}"
fi
done <<< "${EXTRA_VOLUMES}"
fi
echo " mem_limit: ${MEMORY_LIMIT}"
echo " mem_reservation: ${MEMORY_RESERVATION}"
echo " networks:"
echo " - codehunters_net"
echo " logging:"
echo " driver: \"json-file\""
echo " options:"
echo " max-size: \"10m\""
echo " max-file: \"3\""
echo ""
echo "volumes:"
echo " shared_logs:"
echo " external: true"
echo ""
echo "networks:"
echo " codehunters_net:"
echo " external: true"
} > "${COMPOSE_FILE_NAME}"
# Create start.sh (rm + recreate so ownership matches current user;
# avoids "chmod: Operation not permitted" on hosts where the file was
# left behind by a different deploy user.)
echo "Creating start.sh..."
sudo rm -f start.sh
printf "#!/usr/bin/env bash\nset -euo pipefail\nCOMPOSE_FILE=\"\${COMPOSE_FILE:-%s}\"\nPROJECT_NAME=\"\${PROJECT_NAME:-%s}\"\necho \"==> Starting stack (project: \${PROJECT_NAME})\"\ndocker compose -p \"\${PROJECT_NAME}\" -f \"\${COMPOSE_FILE}\" up -d\necho \"==> Current status:\"\ndocker compose -p \"\${PROJECT_NAME}\" -f \"\${COMPOSE_FILE}\" ps\necho \"==> App logs (last 50 lines):\"\ndocker compose -p \"\${PROJECT_NAME}\" -f \"\${COMPOSE_FILE}\" logs --tail=50\n" "${COMPOSE_FILE_NAME}" "${REPO_NAME}" > start.sh
chmod +x start.sh
# Create stop.sh (rm + recreate; see start.sh note above)
echo "Creating stop.sh..."
sudo rm -f stop.sh
printf "#!/usr/bin/env bash\nset -uo pipefail\nCOMPOSE_FILE=\"\${COMPOSE_FILE:-%s}\"\nPROJECT_NAME=\"\${PROJECT_NAME:-%s}\"\necho \"==> Stopping stack (project: \${PROJECT_NAME})\"\ndocker compose -p \"\${PROJECT_NAME}\" -f \"\${COMPOSE_FILE}\" down --timeout 30 || true\necho \"==> Done.\"\n" "${COMPOSE_FILE_NAME}" "${REPO_NAME}" > stop.sh
chmod +x stop.sh
# Pull latest image from ECR
echo "Pulling latest image..."
docker pull "${IMAGE_REF}"
# Stop existing container if running (force-kill fallback if hung)
if docker ps -aq -f name=${REPO_NAME} 2>/dev/null | grep -q .; then
echo "Stopping existing container (graceful, 30s timeout)..."
timeout 60 ./stop.sh || true
if docker ps -q -f name=${REPO_NAME} 2>/dev/null | grep -q .; then
echo "Container still running, force-killing..."
docker kill ${REPO_NAME} 2>/dev/null || true
docker rm -f ${REPO_NAME} 2>/dev/null || true
fi
fi
# Remove every image of this repo except the one about to start. By image
# ID, not by tag: an image pulled by digest has no tag, lists as
# repo:<none>, and `docker rmi repo:<none>` fails, so a tag filter would
# never remove anything once digest deploys start. -f because one ID can
# carry several refs (a tag and a digest), and rmi without it refuses.
echo "Removing old images for ${REPO_NAME}..."
KEEP=$(docker image inspect --format '{{.Id}}' "${IMAGE_REF}")
docker images "${AWS_ECR_URL}/${REPO_NAME}" --no-trunc --format '{{.ID}}' \
| sort -u | grep -vxF "$KEEP" | xargs -r docker rmi -f 2>/dev/null || true
# Start new container
echo "Starting new container..."
./start.sh
echo "Deployment completed successfully!"
DEPLOY_SCRIPT
SSH_COMMON_OPTS="-i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new -o ConnectTimeout=60 -o ServerAliveInterval=15 -o ServerAliveCountMax=20"
# Retry helper: SSH transport can drop transiently. Retry up to 3 times.
run_with_retry() {
local attempt=1
local max_attempts=3
local delay=15
while [ "$attempt" -le "$max_attempts" ]; do
echo "Attempt $attempt/$max_attempts: $*"
if "$@"; then
return 0
fi
local rc=$?
echo "Attempt $attempt failed (exit $rc)"
if [ "$attempt" -lt "$max_attempts" ]; then
echo "Retrying in ${delay}s..."
sleep "$delay"
delay=$((delay * 2))
fi
attempt=$((attempt + 1))
done
return 1
}
# Copy script to EC2 (with retry). Unreachable host = HARD FAILURE (no silent skip):
# a green-but-not-deployed pipeline hides real problems.
run_with_retry bash -c "scp $SSH_COMMON_OPTS /tmp/deploy.sh ${AWS_EC2_USER}@${AWS_EC2_HOST}:~/deploy.sh" || {
echo "::error::scp failed after retries — host '${AWS_EC2_HOST}' is unreachable."
echo "::error::VPN flow: ensure the WireGuard tunnel is up AND AWS_EC2_HOST is the PRIVATE IP (not a DNS name — 'Temporary failure in name resolution' means a name was used)."
echo "status=failed_unreachable" >> $GITHUB_OUTPUT
exit 1
}
# Execute deploy script (with retry on transport error)
DEPLOY_RC=0
# The deploy environment travels over stdin, never as an ssh argument.
# Whatever is interpolated into the remote command becomes argv of the shell
# sshd spawns, and /proc/<pid>/cmdline is world-readable on a stock AMI for
# the whole deploy — so AWS_SECRET_ACCESS_KEY would be readable by any local
# account on the box. The WireGuard tunnel authenticates the path but does
# nothing about who can read a process list on the far end. printf %q quotes
# each value so the file is safe to source whatever the secret contains.
write_remote_env() {
{
printf 'REPO_NAME=%q\n' "$REPO_NAME"
printf 'IMAGE_TAG=%q\n' "$IMAGE_TAG"
printf 'IMAGE_REF=%q\n' "$IMAGE_REF"
printf 'AWS_ECR_URL=%q\n' "$AWS_ECR_URL"
# The keys stay on the host side: deploy.sh needs them for the ECR login,
# then unsets them before compose runs, so nothing can pass them on.
printf 'AWS_ACCESS_KEY_ID=%q\n' "$AWS_ACCESS_KEY_ID"
printf 'AWS_SECRET_ACCESS_KEY=%q\n' "$AWS_SECRET_ACCESS_KEY"
printf 'AWS_REGION=%q\n' "$AWS_REGION"
printf 'APP_PORT=%q\n' "$APP_PORT"
printf 'ENVIRONMENT=%q\n' "$ENVIRONMENT"
printf 'DOCKER_PLATFORM=%q\n' "$DOCKER_PLATFORM"
printf 'MEMORY_LIMIT=%q\n' "$MEMORY_LIMIT"
printf 'MEMORY_RESERVATION=%q\n' "$MEMORY_RESERVATION"
printf 'INTERNAL_PORT=%q\n' "$INTERNAL_PORT"
printf 'CONTAINER_ENV_VARS_B64=%q\n' "$CONTAINER_ENV_VARS_B64"
printf 'EXTRA_VOLUMES_B64=%q\n' "$EXTRA_VOLUMES_B64"
} | ssh $SSH_COMMON_OPTS "${AWS_EC2_USER}@${AWS_EC2_HOST}" 'umask 077 && cat > ~/deploy.env'
}
# The env file is rewritten on every attempt because the remote side always
# deletes it, so a retry still finds what it needs.
deploy_once() {
write_remote_env || return $?
ssh $SSH_COMMON_OPTS "${AWS_EC2_USER}@${AWS_EC2_HOST}" '
set +e
chmod +x ~/deploy.sh
set -a; . ~/deploy.env; set +a
~/deploy.sh
rc=$?
rm -f ~/deploy.env
if [ "$rc" -eq 0 ]; then rm -f ~/deploy.sh; fi
exit $rc
'
}
run_with_retry deploy_once || DEPLOY_RC=$?
# Post-deploy verification: if container is running w/ correct image, success regardless of transport error
echo "Verifying container state..."
# docker inspect, not docker ps: for a container created from repo@sha256:…
# `docker ps` prints the short image ID, while .Config.Image keeps the ref
# the container was created from, in both the digest and the tag form.
VERIFY_OUTPUT=$(ssh $SSH_COMMON_OPTS ${AWS_EC2_USER}@${AWS_EC2_HOST} "docker inspect --format '{{.Config.Image}}|{{.State.Status}}' '${REPO_NAME}' 2>/dev/null" || echo "")
RUNNING_IMAGE=$(echo "$VERIFY_OUTPUT" | cut -d'|' -f1)
RUNNING_STATUS=$(echo "$VERIFY_OUTPUT" | cut -d'|' -f2)
echo "Container image: '$RUNNING_IMAGE'"
echo "Container status: '$RUNNING_STATUS'"
# Exact equality, not a pattern: a tag 1.2.3 must not match 1.2.30, and
# '@sha256:' needs no escaping. The status check keeps what `docker ps`
# used to imply, since inspect also answers for a stopped container.
if [ "$RUNNING_IMAGE" = "$IMAGE_REF" ] && [ "$RUNNING_STATUS" = "running" ]; then
echo "Container is running with target image."
echo "status=success" >> $GITHUB_OUTPUT
elif [ "$DEPLOY_RC" -eq 0 ]; then
echo "Deploy script completed but verification could not confirm running container."
echo "status=success" >> $GITHUB_OUTPUT
else
echo "::error::Deploy failed (rc=$DEPLOY_RC) and container is not running with target image."
echo "status=failure" >> $GITHUB_OUTPUT
exit "$DEPLOY_RC"
fi
# Summary
echo "### Deployment Completed (via WireGuard VPN)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| **Server** | $AWS_EC2_HOST |" >> $GITHUB_STEP_SUMMARY
echo "| **VPN** | WireGuard (wg0) |" >> $GITHUB_STEP_SUMMARY
echo "| **Application** | $REPO_NAME |" >> $GITHUB_STEP_SUMMARY
echo "| **Image Tag** | $IMAGE_TAG |" >> $GITHUB_STEP_SUMMARY
echo "| **Environment** | $ENVIRONMENT |" >> $GITHUB_STEP_SUMMARY
echo "| **Port** | $APP_PORT |" >> $GITHUB_STEP_SUMMARY
echo "| **Directory** | /opt/docker/$REPO_NAME |" >> $GITHUB_STEP_SUMMARY
- name: Teardown WireGuard tunnel
if: always() && steps.wg.outputs.started == 'true'
env:
WG_IFACE: wg0
run: |
echo "Bringing down WireGuard interface ${WG_IFACE}..."
sudo wg-quick down "$WG_IFACE" || true
sudo rm -f "/etc/wireguard/${WG_IFACE}.conf"