name: Shared - Deploy to EC2
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 environment: description: 'GitHub environment (develop, prod)' required: false type: string default: 'develop' 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
outputs: deploy_status: description: 'Deployment status' value: ${{ jobs.deploy.outputs.status }} deployed_image: description: 'Deployed image tag' value: ${{ jobs.deploy.outputs.image }}
jobs: deploy: name: Deploy to EC2 runs-on: ${{ inputs.runner }} environment: ${{ inputs.environment }} timeout-minutes: 15
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: 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 }} 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: | # Setup SSH mkdir -p ~/.ssh echo "$AWS_EC2_SSH_KEY" > ~/.ssh/deploy_key chmod 600 ~/.ssh/deploy_key ssh-keyscan -H $AWS_EC2_HOST >> ~/.ssh/known_hosts 2>/dev/null
# 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 -e
DEPLOY_DIR="/opt/docker/${REPO_NAME}"
echo "==========================================" echo "Deploying ${REPO_NAME}" echo "Image tag: ${IMAGE_TAG}" echo "Environment: ${ENVIRONMENT}" echo "=========================================="
# Create directories if not exists if [ ! -d "$DEPLOY_DIR" ]; then echo "Creating directory: $DEPLOY_DIR" sudo mkdir -p "$DEPLOY_DIR" sudo chown ${USER}:${USER} "$DEPLOY_DIR" fi
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}
# Decode env vars from base64 CONTAINER_ENV_VARS=$(echo "${CONTAINER_ENV_VARS_B64}" | base64 -d) EXTRA_VOLUMES=$(echo "${EXTRA_VOLUMES_B64}" | base64 -d)
# Create docker-compose.yml (standard name) COMPOSE_FILE_NAME="docker-compose.yml" echo "Creating ${COMPOSE_FILE_NAME}..." { echo "services:" echo " ${REPO_NAME}:" echo " image: ${AWS_ECR_URL}/${REPO_NAME}:${IMAGE_TAG}" 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_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}" echo " - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}" 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 ${AWS_ECR_URL}/${REPO_NAME}:${IMAGE_TAG}
# 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 only old images for this repo (keep the new one) echo "Removing old images for ${REPO_NAME}..." docker images "${AWS_ECR_URL}/${REPO_NAME}" --format '{{.Repository}}:{{.Tag}}' \ | grep -v ":${IMAGE_TAG}$" \ | xargs -r docker rmi 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=no -o ConnectTimeout=30 -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) run_with_retry bash -c "scp $SSH_COMMON_OPTS /tmp/deploy.sh ${AWS_EC2_USER}@${AWS_EC2_HOST}:~/deploy.sh" || { echo "::warning::scp failed after retries — server may be unreachable. Skipping remote deploy." echo "status=skipped_unreachable" >> $GITHUB_OUTPUT exit 0 }
# Execute deploy script (with retry on transport error) DEPLOY_RC=0 run_with_retry bash -c "ssh $SSH_COMMON_OPTS ${AWS_EC2_USER}@${AWS_EC2_HOST} \" chmod +x ~/deploy.sh && \ REPO_NAME='${REPO_NAME}' \ IMAGE_TAG='${IMAGE_TAG}' \ AWS_ECR_URL='${AWS_ECR_URL}' \ AWS_ACCESS_KEY_ID='${AWS_ACCESS_KEY_ID}' \ AWS_SECRET_ACCESS_KEY='${AWS_SECRET_ACCESS_KEY}' \ AWS_REGION='${AWS_REGION}' \ APP_PORT='${APP_PORT}' \ ENVIRONMENT='${ENVIRONMENT}' \ DOCKER_PLATFORM='${DOCKER_PLATFORM}' \ MEMORY_LIMIT='${MEMORY_LIMIT}' \ MEMORY_RESERVATION='${MEMORY_RESERVATION}' \ INTERNAL_PORT='${INTERNAL_PORT}' \ CONTAINER_ENV_VARS_B64='${CONTAINER_ENV_VARS_B64}' \ EXTRA_VOLUMES_B64='${EXTRA_VOLUMES_B64}' \ ~/deploy.sh && \ rm -f ~/deploy.sh \"" || DEPLOY_RC=$?
# Post-deploy verification: if container is running w/ correct image, success regardless of transport error echo "Verifying container state..." VERIFY_OUTPUT=$(ssh $SSH_COMMON_OPTS ${AWS_EC2_USER}@${AWS_EC2_HOST} "docker ps --filter name=^${REPO_NAME}$ --format '{{.Image}}|{{.Status}}' 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'"
if echo "$RUNNING_IMAGE" | grep -q "${REPO_NAME}:${IMAGE_TAG}$"; 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" >> $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 "| **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 Shared (cross-cutting)· Reusable workflow ·on: workflow_call
Shared Deploy Ec2
Shared - Deploy to EC2
.github/workflows/shared-deploy-ec2.yml