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 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' 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 }}
# 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 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 }} 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=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) # Unreachable host = HARD FAILURE (no silent skip): a green-but-not-deployed pipeline hides 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. Verify AWS_EC2_HOST (use an IP, not an unresolvable DNS name) and connectivity." 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. 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" >> $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_SUMMARYShared (cross-cutting)·Reusable workflow·on: workflow_call
Shared Deploy Ec2
Shared - Deploy to EC2
.github/workflows/shared-deploy-ec2.yml