Skip to content

Deploy

Deploy #204

Workflow file for this run

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."