Deploy #204
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: Deploy | |
| on: | |
| workflow_dispatch: | |
| push: | |
| branches: | |
| - main | |
| permissions: | |
| contents: read | |
| packages: write | |
| concurrency: | |
| group: deploy-production | |
| cancel-in-progress: false | |
| env: | |
| REGISTRY: ghcr.io | |
| IMAGE_NAME: devkor-github/ontime-back | |
| IMAGE_TAG: ${{ github.sha }} | |
| PUBLIC_BACKEND_HOST: ontime-back.duckdns.org | |
| jobs: | |
| build-and-push: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v3 | |
| - name: Log in to GHCR | |
| uses: docker/login-action@v3 | |
| with: | |
| registry: ${{ env.REGISTRY }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Build and push image | |
| uses: docker/build-push-action@v6 | |
| with: | |
| context: ./ontime-back | |
| file: ./ontime-back/Dockerfile | |
| push: true | |
| tags: | | |
| ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} | |
| ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:deploy-latest | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max | |
| deploy-to-ec2: | |
| needs: build-and-push | |
| runs-on: ubuntu-latest | |
| environment: production | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| - name: Upload compose file to EC2 | |
| uses: appleboy/scp-action@v0.1.7 | |
| with: | |
| host: ${{ secrets.EC2_HOST }} | |
| username: ${{ secrets.EC2_USER }} | |
| key: ${{ secrets.EC2_SSH_KEY }} | |
| source: "ontime-back/docker-compose.yml" | |
| target: "/home/ubuntu/OnTime-back" | |
| strip_components: 1 | |
| - name: Upload Caddyfile to EC2 | |
| uses: appleboy/scp-action@v0.1.7 | |
| with: | |
| host: ${{ secrets.EC2_HOST }} | |
| username: ${{ secrets.EC2_USER }} | |
| key: ${{ secrets.EC2_SSH_KEY }} | |
| source: "ontime-back/Caddyfile" | |
| target: "/home/ubuntu/OnTime-back" | |
| strip_components: 1 | |
| - name: Pull image and restart container | |
| uses: appleboy/ssh-action@v1.0.3 | |
| with: | |
| host: ${{ secrets.EC2_HOST }} | |
| username: ${{ secrets.EC2_USER }} | |
| key: ${{ secrets.EC2_SSH_KEY }} | |
| script: | | |
| set -eu | |
| DEPLOY_DIR="/home/ubuntu/OnTime-back" | |
| CONTAINER_NAME="ontime-container" | |
| PUBLIC_BACKEND_HOST="${{ env.PUBLIC_BACKEND_HOST }}" | |
| mkdir -p "$DEPLOY_DIR" | |
| cd "$DEPLOY_DIR" | |
| umask 077 | |
| cat > .env <<'EOF' | |
| IMAGE_TAG=${{ env.IMAGE_TAG }} | |
| BACKEND_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} | |
| BACKEND_CONTAINER_NAME=ontime-container | |
| BACKEND_HTTP_PORT=${{ secrets.BACKEND_HTTP_PORT || '127.0.0.1:8080' }} | |
| BACKEND_MEMORY_LIMIT=${{ secrets.BACKEND_MEMORY_LIMIT || '768m' }} | |
| BACKEND_CPU_LIMIT=${{ secrets.BACKEND_CPU_LIMIT || '1.0' }} | |
| SERVER_PORT=8080 | |
| SPRING_PROFILES_ACTIVE=prod | |
| JAVA_TOOL_OPTIONS=-XX:InitialRAMPercentage=50.0 -XX:MaxRAMPercentage=75.0 -Djava.security.egd=file:/dev/./urandom | |
| SPRING_APPLICATION_NAME=${{ secrets.SPRING_APPLICATION_NAME }} | |
| SPRING_DATASOURCE_URL=${{ secrets.SPRING_DATASOURCE_URL }} | |
| SPRING_DATASOURCE_USERNAME=${{ secrets.SPRING_DATASOURCE_USERNAME }} | |
| SPRING_DATASOURCE_PASSWORD=${{ secrets.SPRING_DATASOURCE_PASSWORD }} | |
| SPRING_DATASOURCE_DRIVER_CLASS_NAME=com.mysql.cj.jdbc.Driver | |
| SPRING_JPA_HIBERNATE_DDL_AUTO=validate | |
| SPRING_FLYWAY_ENABLED=true | |
| SPRING_FLYWAY_BASELINE_ON_MIGRATE=false | |
| JWT_SECRET_KEY=${{ secrets.JWT_SECRETKEY }} | |
| JWT_ACCESS_EXPIRATION=${{ secrets.JWT_ACCESS_EXPIRATION }} | |
| JWT_REFRESH_EXPIRATION=${{ secrets.JWT_REFRESH_EXPIRATION }} | |
| JWT_ACCESS_HEADER=${{ secrets.JWT_ACCESS_HEADER }} | |
| JWT_REFRESH_HEADER=${{ secrets.JWT_REFRESH_HEADER }} | |
| GOOGLE_WEB_CLIENT_ID=${{ secrets.GOOGLE_WEB_CLIENT_ID }} | |
| GOOGLE_APP_CLIENT_ID=${{ secrets.GOOGLE_APP_CLIENT_ID }} | |
| SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_SECRET=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_SECRET }} | |
| SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_SCOPE=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_SCOPE }} | |
| SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_REDIRECT_URI=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_REDIRECT_URI }} | |
| SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_AUTHORIZATION_GRANT_TYPE=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_AUTHORIZATION_GRANT_TYPE }} | |
| SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_NAME=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_NAME }} | |
| SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_AUTHORIZATION_URI=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_AUTHORIZATION_URI }} | |
| SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_TOKEN_URI=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_TOKEN_URI }} | |
| SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_USER_INFO_URI=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_USER_INFO_URI }} | |
| SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_USER_NAME_ATTRIBUTE=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_GOOGLE_USER_NAME_ATTRIBUTE }} | |
| SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_CLIENT_ID=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_CLIENT_ID }} | |
| SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_SCOPE=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_SCOPE }} | |
| SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_REDIRECT_URI=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_REDIRECT_URI }} | |
| SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_AUTHORIZATION_GRANT_TYPE=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_AUTHORIZATION_GRANT_TYPE }} | |
| SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_CLIENT_NAME=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_CLIENT_NAME }} | |
| SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_AUTHORIZATION_URI=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_AUTHORIZATION_URI }} | |
| SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_TOKEN_URI=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_TOKEN_URI }} | |
| SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_USER_INFO_URI=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_USER_INFO_URI }} | |
| SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_USER_NAME_ATTRIBUTE=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KAKAO_USER_NAME_ATTRIBUTE }} | |
| APPLE_CLIENT_ID=${{ secrets.APPLE_CLIENT_ID }} | |
| APPLE_TEAM_ID=${{ secrets.APPLE_TEAM_ID }} | |
| APPLE_LOGIN_KEY=${{ secrets.APPLE_LOGIN_KEY }} | |
| APPLE_PRIVATE_KEY_BASE64=${{ secrets.APPLE_PRIVATE_KEY_BASE64 }} | |
| FEATURE_APPLE_LOGIN_ENABLED=${{ secrets.FEATURE_APPLE_LOGIN_ENABLED || 'true' }} | |
| FIREBASE_CREDENTIALS_BASE64=${{ secrets.FIREBASE_CREDENTIALS_BASE64 }} | |
| EOF | |
| fail_deploy() { | |
| echo "Unsafe production database configuration: $1" >&2 | |
| exit 1 | |
| } | |
| get_env_value() { | |
| grep -E "^$1=" .env | tail -n 1 | cut -d= -f2- | |
| } | |
| install_caddy() { | |
| if command -v caddy >/dev/null 2>&1; then | |
| return | |
| fi | |
| echo "Installing Caddy from the official package repository..." | |
| sudo apt-get update | |
| sudo apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg | |
| curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ | |
| | sudo gpg --dearmor --yes -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg | |
| curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ | |
| | sudo tee /etc/apt/sources.list.d/caddy-stable.list >/dev/null | |
| sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg | |
| sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list | |
| sudo apt-get update | |
| sudo apt-get install -y caddy | |
| } | |
| DB_URL="$(get_env_value SPRING_DATASOURCE_URL)" | |
| DB_USERNAME="$(get_env_value SPRING_DATASOURCE_USERNAME)" | |
| DB_PASSWORD="$(get_env_value SPRING_DATASOURCE_PASSWORD)" | |
| BACKEND_HTTP_BIND="$(get_env_value BACKEND_HTTP_PORT)" | |
| DDL_AUTO="$(get_env_value SPRING_JPA_HIBERNATE_DDL_AUTO)" | |
| FLYWAY_BASELINE="$(get_env_value SPRING_FLYWAY_BASELINE_ON_MIGRATE)" | |
| NORMALIZED_DB_URL="$(printf '%s' "$DB_URL" | tr '[:upper:]' '[:lower:]')" | |
| NORMALIZED_DB_USERNAME="$(printf '%s' "$DB_USERNAME" | tr '[:upper:]' '[:lower:]')" | |
| DB_URL_NO_PREFIX="${DB_URL#jdbc:mysql://}" | |
| [ "$DB_URL_NO_PREFIX" != "$DB_URL" ] || fail_deploy "SPRING_DATASOURCE_URL must start with jdbc:mysql://." | |
| DB_ADDRESS="${DB_URL_NO_PREFIX%%/*}" | |
| DB_HOST="${DB_ADDRESS%%:*}" | |
| DB_PORT="${DB_ADDRESS#*:}" | |
| [ "$DB_PORT" != "$DB_ADDRESS" ] || DB_PORT="3306" | |
| DB_NAME_AND_QUERY="${DB_URL_NO_PREFIX#*/}" | |
| DB_NAME="$(printf '%s' "$DB_NAME_AND_QUERY" | sed 's/[?;].*$//')" | |
| [ -n "$DB_URL" ] || fail_deploy "SPRING_DATASOURCE_URL is required." | |
| [ -n "$DB_USERNAME" ] || fail_deploy "SPRING_DATASOURCE_USERNAME is required." | |
| [ -n "$DB_PASSWORD" ] || fail_deploy "SPRING_DATASOURCE_PASSWORD is required." | |
| [ -n "$DB_HOST" ] || fail_deploy "SPRING_DATASOURCE_URL must include an RDS host." | |
| [ "$DB_NAME" = "ontime_prod" ] || fail_deploy "SPRING_DATASOURCE_URL must use the ontime_prod database." | |
| [ "$NORMALIZED_DB_USERNAME" != "root" ] || fail_deploy "SPRING_DATASOURCE_USERNAME must not be root." | |
| [ "$BACKEND_HTTP_BIND" = "127.0.0.1:8080" ] || fail_deploy "BACKEND_HTTP_PORT must be 127.0.0.1:8080 so public traffic goes through Caddy HTTPS." | |
| [ "$DDL_AUTO" = "validate" ] || fail_deploy "SPRING_JPA_HIBERNATE_DDL_AUTO must be validate." | |
| [ "$FLYWAY_BASELINE" = "false" ] || fail_deploy "SPRING_FLYWAY_BASELINE_ON_MIGRATE must be false." | |
| case "$NORMALIZED_DB_URL" in | |
| *localhost*|*127.0.0.1*|*host.docker.internal*) fail_deploy "SPRING_DATASOURCE_URL must point to private RDS, not a local database." ;; | |
| esac | |
| case "$(printf '%s' "$DB_HOST" | tr '[:upper:]' '[:lower:]')" in | |
| *.rds.amazonaws.com) ;; | |
| *) fail_deploy "SPRING_DATASOURCE_URL host must be an RDS endpoint." ;; | |
| esac | |
| case "$NORMALIZED_DB_URL" in | |
| *allowpublickeyretrieval=true*) fail_deploy "SPRING_DATASOURCE_URL must not enable allowPublicKeyRetrieval." ;; | |
| esac | |
| case "$NORMALIZED_DB_URL" in | |
| *createdatabaseifnotexist=true*) fail_deploy "SPRING_DATASOURCE_URL must not create databases at startup." ;; | |
| esac | |
| case "$NORMALIZED_DB_URL" in | |
| *usessl=false*) fail_deploy "SPRING_DATASOURCE_URL must not disable TLS." ;; | |
| esac | |
| case "$NORMALIZED_DB_URL" in | |
| *sslmode=required*|*sslmode=verify_ca*|*sslmode=verify_identity*|*usessl=true*) ;; | |
| *) fail_deploy "SPRING_DATASOURCE_URL must declare useSSL=true, sslMode=REQUIRED, VERIFY_CA, or VERIFY_IDENTITY." ;; | |
| esac | |
| echo "Checking EC2-to-RDS TCP connectivity for $DB_HOST:$DB_PORT..." | |
| if command -v nc >/dev/null 2>&1; then | |
| nc -zv "$DB_HOST" "$DB_PORT" | |
| else | |
| timeout 5 bash -c "</dev/tcp/$DB_HOST/$DB_PORT" | |
| fi | |
| echo "${{ secrets.GHCR_READ_TOKEN }}" | sudo docker login ghcr.io -u "${{ secrets.GHCR_USERNAME }}" --password-stdin | |
| if sudo docker compose version >/dev/null 2>&1; then | |
| sudo docker compose pull | |
| sudo docker compose up -d --remove-orphans | |
| else | |
| sudo docker-compose pull | |
| sudo docker-compose up -d --remove-orphans | |
| fi | |
| HEALTHY=false | |
| for attempt in $(seq 1 30); do | |
| STATUS="$(sudo docker inspect -f '{{.State.Health.Status}}' "$CONTAINER_NAME" 2>/dev/null || true)" | |
| if [ "$STATUS" = "healthy" ]; then | |
| echo "Container is healthy." | |
| HEALTHY=true | |
| break | |
| fi | |
| echo "Waiting for healthy container status; current status: ${STATUS:-unknown}" | |
| sleep 5 | |
| done | |
| if [ "$HEALTHY" != "true" ]; then | |
| sudo docker logs --tail=200 "$CONTAINER_NAME" || true | |
| exit 1 | |
| fi | |
| echo "Configuring Caddy reverse proxy for https://$PUBLIC_BACKEND_HOST..." | |
| [ -f "$DEPLOY_DIR/Caddyfile" ] || fail_deploy "Caddyfile was not uploaded to $DEPLOY_DIR." | |
| if ! getent hosts "$PUBLIC_BACKEND_HOST" >/dev/null 2>&1; then | |
| fail_deploy "DNS for $PUBLIC_BACKEND_HOST must resolve before Caddy can issue a TLS certificate." | |
| fi | |
| install_caddy | |
| sudo install -o root -g root -m 0644 "$DEPLOY_DIR/Caddyfile" /etc/caddy/Caddyfile | |
| sudo caddy validate --config /etc/caddy/Caddyfile | |
| sudo systemctl enable caddy | |
| sudo systemctl reload caddy || sudo systemctl restart caddy | |
| sudo systemctl is-active --quiet caddy | |
| echo "Verifying HTTPS through local Caddy..." | |
| HTTPS_HEALTHY=false | |
| for attempt in $(seq 1 6); do | |
| if curl -fsS \ | |
| --resolve "$PUBLIC_BACKEND_HOST:443:127.0.0.1" \ | |
| "https://$PUBLIC_BACKEND_HOST/actuator/health/readiness"; then | |
| HTTPS_HEALTHY=true | |
| break | |
| fi | |
| echo "Waiting for HTTPS readiness through Caddy..." | |
| sleep 10 | |
| done | |
| if [ "$HTTPS_HEALTHY" != "true" ]; then | |
| echo "HTTPS is not ready yet. Caddy is running and will continue ACME certificate retries in the background." | |
| sudo journalctl -u caddy --no-pager -n 120 || true | |
| exit 0 | |
| fi | |
| echo "HTTPS is healthy at https://$PUBLIC_BACKEND_HOST." |