diff --git a/.github/workflows/deploy-ec2.yml b/.github/workflows/deploy-ec2.yml deleted file mode 100644 index a1b02ee..0000000 --- a/.github/workflows/deploy-ec2.yml +++ /dev/null @@ -1,484 +0,0 @@ -name: Deploy API to EC2 - -on: - push: - branches: - - main - - codex/server-deploy-contract-check-fix - workflow_dispatch: - inputs: - image_tag: - description: Docker image tag to build and deploy. Defaults to the current commit SHA. - required: false - type: string - aws_region: - description: Optional AWS region used to open the EC2 API ingress rule. - required: false - type: string - security_group_id: - description: Optional EC2 security group ID used to open the API ingress rule. - required: false - type: string - -concurrency: - group: deploy-api-${{ github.ref }} - cancel-in-progress: true - -jobs: - build-and-push: - name: Build and push Docker image - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - image_tag: ${{ steps.meta.outputs.image_tag }} - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Validate Docker Hub secrets - shell: bash - env: - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} - run: | - set -euo pipefail - missing=() - for name in DOCKERHUB_USERNAME DOCKERHUB_TOKEN; do - if [ -z "${!name}" ]; then - missing+=("$name") - fi - done - - if [ "${#missing[@]}" -gt 0 ]; then - printf 'Missing required secrets: %s\n' "${missing[*]}" - exit 1 - fi - - - name: Set image tag - id: meta - shell: bash - run: | - set -euo pipefail - image_tag="${GITHUB_SHA::12}" - - if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.image_tag }}" ]; then - image_tag="${{ inputs.image_tag }}" - fi - - if [[ ! "$image_tag" =~ ^[A-Za-z0-9_.-]{1,128}$ ]]; then - echo "Invalid Docker image tag: ${image_tag}" - exit 1 - fi - - echo "image_tag=${image_tag}" >> "$GITHUB_OUTPUT" - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - push: true - tags: | - ${{ secrets.DOCKERHUB_USERNAME }}/soundlog-server:${{ steps.meta.outputs.image_tag }} - ${{ secrets.DOCKERHUB_USERNAME }}/soundlog-server:latest - cache-from: type=gha - cache-to: type=gha,mode=max - - deploy: - name: Pull and restart on EC2 - runs-on: ubuntu-latest - needs: build-and-push - permissions: - contents: read - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Validate deployment secrets - shell: bash - env: - EC2_HOST: ${{ secrets.EC2_HOST }} - EC2_USER: ${{ secrets.EC2_USER }} - EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }} - EC2_SSH_PORT: ${{ secrets.EC2_SSH_PORT }} - EC2_APP_DIR: ${{ secrets.EC2_APP_DIR }} - PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }} - run: | - set -euo pipefail - missing=() - for name in EC2_HOST EC2_USER EC2_SSH_KEY EC2_SSH_PORT EC2_APP_DIR PRODUCTION_ENV; do - if [ -z "${!name}" ]; then - missing+=("$name") - fi - done - - for key in API_PORT CLIENT_URL CLIENT_URLS POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DB JWT_SECRET; do - value="$(sed -n "s/^${key}=//p" <<< "$PRODUCTION_ENV" | tail -n 1)" - if [ -z "$value" ]; then - missing+=("PRODUCTION_ENV.${key}") - fi - done - - if grep -q "^DOCKER_IMAGE=" <<< "$PRODUCTION_ENV"; then - echo "PRODUCTION_ENV must not include DOCKER_IMAGE. The workflow injects it at deploy time." - exit 1 - fi - - if grep -q "^DATABASE_URL=" <<< "$PRODUCTION_ENV"; then - echo "PRODUCTION_ENV must not include DATABASE_URL. The workflow injects an encoded internal database URL at deploy time." - exit 1 - fi - - if [ "${#missing[@]}" -gt 0 ]; then - printf 'Missing required secrets: %s\n' "${missing[*]}" - exit 1 - fi - - - name: Create production env file - shell: bash - env: - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - EC2_HOST: ${{ secrets.EC2_HOST }} - IMAGE_TAG: ${{ needs.build-and-push.outputs.image_tag }} - PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }} - run: | - set -euo pipefail - mkdir -p .deploy - api_port="$(sed -n 's/^API_PORT=//p' <<< "$PRODUCTION_ENV" | tail -n 1 | tr -d '\r')" - postgres_user="$(sed -n 's/^POSTGRES_USER=//p' <<< "$PRODUCTION_ENV" | tail -n 1 | tr -d '\r')" - postgres_password="$(sed -n 's/^POSTGRES_PASSWORD=//p' <<< "$PRODUCTION_ENV" | tail -n 1 | tr -d '\r')" - postgres_db="$(sed -n 's/^POSTGRES_DB=//p' <<< "$PRODUCTION_ENV" | tail -n 1 | tr -d '\r')" - - if [[ ! "$api_port" =~ ^[0-9]+$ ]]; then - echo "PRODUCTION_ENV.API_PORT must be a numeric port." - exit 1 - fi - - database_url="$( - POSTGRES_USER="$postgres_user" POSTGRES_PASSWORD="$postgres_password" POSTGRES_DB="$postgres_db" node <<'NODE' - const encode = encodeURIComponent; - const user = encode(process.env.POSTGRES_USER || ''); - const password = encode(process.env.POSTGRES_PASSWORD || ''); - const database = encode(process.env.POSTGRES_DB || ''); - process.stdout.write(`postgresql://${user}:${password}@db:5432/${database}?schema=public`); - NODE - )" - - { - printf 'DOCKER_IMAGE=%s/soundlog-server:%s\n' "$DOCKERHUB_USERNAME" "$IMAGE_TAG" - printf '%s\n' "$PRODUCTION_ENV" | sed '/^DOCKER_IMAGE=/d;/^DATABASE_URL=/d;/^UPLOAD_PUBLIC_BASE_URL=/d' - printf 'DATABASE_URL=%s\n' "$database_url" - printf 'UPLOAD_PUBLIC_BASE_URL=http://%s:%s\n' "$EC2_HOST" "$api_port" - } > .deploy/production.env - - - name: Prepare EC2 app directory - uses: appleboy/ssh-action@v1.2.0 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_SSH_KEY }} - port: ${{ secrets.EC2_SSH_PORT }} - script: | - set -eu - mkdir -p "${{ secrets.EC2_APP_DIR }}" - docker --version - docker compose version - - - name: Copy production compose file - uses: appleboy/scp-action@v0.1.7 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_SSH_KEY }} - port: ${{ secrets.EC2_SSH_PORT }} - source: docker-compose.prod.yml - target: ${{ secrets.EC2_APP_DIR }} - overwrite: true - - - name: Copy production env file - uses: appleboy/scp-action@v0.1.7 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_SSH_KEY }} - port: ${{ secrets.EC2_SSH_PORT }} - source: .deploy/production.env - target: ${{ secrets.EC2_APP_DIR }} - strip_components: 1 - overwrite: true - - - name: Deploy on EC2 - uses: appleboy/ssh-action@v1.2.0 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_SSH_KEY }} - port: ${{ secrets.EC2_SSH_PORT }} - script: | - set -eu - cd "${{ secrets.EC2_APP_DIR }}" - mv production.env .env - - echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin - docker compose -f docker-compose.prod.yml pull api - docker compose -f docker-compose.prod.yml up -d - docker compose -f docker-compose.prod.yml ps - - api_port="$(sed -n 's/^API_PORT=//p' .env | tail -n 1 | tr -d '\r')" - for attempt in $(seq 1 18); do - if curl --fail --silent --show-error --max-time 10 "http://127.0.0.1:${api_port}/v1/health" > /tmp/soundlog-health.json; then - cat /tmp/soundlog-health.json - docker image prune -f - exit 0 - fi - - echo "Waiting for API health check on EC2 (${attempt}/18)..." - sleep 10 - done - - echo "API health check failed on EC2: http://127.0.0.1:${api_port}/v1/health" - docker compose -f docker-compose.prod.yml logs --tail=120 api - exit 1 - - - name: Sync frontend Vercel API origin - shell: bash - env: - EC2_HOST: ${{ secrets.EC2_HOST }} - FRONTEND_VERCEL_PROJECT: ${{ secrets.FRONTEND_VERCEL_PROJECT }} - FRONTEND_VERCEL_SCOPE: ${{ secrets.FRONTEND_VERCEL_SCOPE }} - FRONTEND_VERCEL_TOKEN: ${{ secrets.FRONTEND_VERCEL_TOKEN }} - PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }} - run: | - set -euo pipefail - - if [ -z "$FRONTEND_VERCEL_TOKEN" ]; then - echo "Skipping frontend Vercel API origin sync because FRONTEND_VERCEL_TOKEN is not configured." - exit 0 - fi - - api_port="$(sed -n 's/^API_PORT=//p' <<< "$PRODUCTION_ENV" | tail -n 1 | tr -d '\r')" - if [[ ! "$api_port" =~ ^[0-9]+$ ]]; then - echo "PRODUCTION_ENV.API_PORT must be a numeric port." - exit 1 - fi - - api_origin="http://${EC2_HOST}:${api_port}" - project="${FRONTEND_VERCEL_PROJECT:-sound-log-app}" - scope="${FRONTEND_VERCEL_SCOPE:-mannomis-projects}" - tmpdir="$(mktemp -d)" - - cleanup() { - rm -rf "$tmpdir" - } - trap cleanup EXIT - - npx --yes vercel@54.20.1 link \ - --yes \ - --scope "$scope" \ - --project "$project" \ - --cwd "$tmpdir" \ - --token "$FRONTEND_VERCEL_TOKEN" - - for environment in preview production; do - npx --yes vercel@54.20.1 env add SOUNDLOG_API_ORIGIN "$environment" \ - --force \ - --yes \ - --sensitive \ - --value "$api_origin" \ - --cwd "$tmpdir" \ - --scope "$scope" \ - --token "$FRONTEND_VERCEL_TOKEN" - done - - - name: Verify EC2 API contract - uses: appleboy/ssh-action@v1.2.0 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_SSH_KEY }} - port: ${{ secrets.EC2_SSH_PORT }} - script: | - set -eu - cd "${{ secrets.EC2_APP_DIR }}" - docker compose -f docker-compose.prod.yml exec -T \ - -e PUBLIC_API_BASE_URL=http://127.0.0.1:4000 \ - api \ - node scripts/check-public-api-contract.mjs - - - name: Diagnose EC2 API network - uses: appleboy/ssh-action@v1.2.0 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_SSH_KEY }} - port: ${{ secrets.EC2_SSH_PORT }} - script: | - set -eu - cd "${{ secrets.EC2_APP_DIR }}" - - api_port="$(sed -n 's/^API_PORT=//p' .env | tail -n 1 | tr -d '\r')" - echo "API_PORT=${api_port}" - - echo "::group::Docker compose state" - docker compose -f docker-compose.prod.yml ps - docker compose -f docker-compose.prod.yml port api 4000 || true - echo "::endgroup::" - - echo "::group::Listening sockets" - if command -v ss >/dev/null 2>&1; then - sudo -n ss -ltnp 2>/dev/null | grep -E "(:${api_port}|:4000|:80|:443)" || ss -ltnp | grep -E "(:${api_port}|:4000|:80|:443)" || true - else - netstat -ltnp 2>/dev/null | grep -E "(:${api_port}|:4000|:80|:443)" || true - fi - echo "::endgroup::" - - echo "::group::EC2 local health" - curl --fail --silent --show-error --max-time 5 "http://127.0.0.1:${api_port}/v1/health" || true - printf '\n' - metadata_token="$(curl --silent --show-error --max-time 5 -X PUT \ - -H 'X-aws-ec2-metadata-token-ttl-seconds: 60' \ - http://169.254.169.254/latest/api/token || true)" - metadata_curl() { - if [ -n "$metadata_token" ]; then - curl --silent --show-error --max-time 5 \ - -H "X-aws-ec2-metadata-token: ${metadata_token}" \ - "http://169.254.169.254/latest/meta-data/$1" || true - else - curl --silent --show-error --max-time 5 \ - "http://169.254.169.254/latest/meta-data/$1" || true - fi - } - public_ip="$(metadata_curl public-ipv4)" - echo "EC2 metadata public-ipv4=${public_ip:-unavailable}" - if [ -n "$public_ip" ]; then - curl --fail --silent --show-error --max-time 5 "http://${public_ip}:${api_port}/v1/health" || true - printf '\n' - fi - echo "::endgroup::" - - echo "::group::EC2 metadata network identity" - echo "instance-id=$(metadata_curl instance-id)" - echo "placement-region=$(metadata_curl placement/region)" - echo "placement-availability-zone=$(metadata_curl placement/availability-zone)" - macs="$(metadata_curl network/interfaces/macs/)" - printf 'network-interface-macs=%s\n' "${macs:-unavailable}" - first_mac="$(printf '%s\n' "$macs" | head -n 1)" - if [ -n "$first_mac" ]; then - metadata_mac_path="network/interfaces/macs/${first_mac}" - vpc_id="$(metadata_curl "${metadata_mac_path}vpc-id")" - subnet_id="$(metadata_curl "${metadata_mac_path}subnet-id")" - security_groups="$(metadata_curl "${metadata_mac_path}security-groups")" - security_group_ids="$(metadata_curl "${metadata_mac_path}security-group-ids")" - echo "vpc-id=${vpc_id}" - echo "subnet-id=${subnet_id}" - echo "security-groups=${security_groups}" - echo "security-group-ids=${security_group_ids}" - fi - echo "::endgroup::" - - echo "::group::Host firewall state" - if command -v firewall-cmd >/dev/null 2>&1; then - sudo -n firewall-cmd --state 2>/dev/null || true - sudo -n firewall-cmd --list-all 2>/dev/null || true - fi - if command -v ufw >/dev/null 2>&1; then - sudo -n ufw status verbose 2>/dev/null || true - fi - sudo -n iptables -S INPUT 2>/dev/null || true - echo "::endgroup::" - - - name: Open EC2 API ingress when AWS credentials are configured - shell: bash - env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_SESSION_TOKEN: ${{ secrets.AWS_SESSION_TOKEN }} - AWS_REGION_SECRET: ${{ secrets.AWS_REGION }} - AWS_REGION_INPUT: ${{ inputs.aws_region }} - EC2_SECURITY_GROUP_ID_SECRET: ${{ secrets.EC2_SECURITY_GROUP_ID }} - EC2_SECURITY_GROUP_ID_INPUT: ${{ inputs.security_group_id }} - PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }} - run: | - set -euo pipefail - api_port="$(sed -n 's/^API_PORT=//p' <<< "$PRODUCTION_ENV" | tail -n 1 | tr -d '\r')" - aws_region="${AWS_REGION_INPUT:-${AWS_REGION_SECRET:-}}" - security_group_id="${EC2_SECURITY_GROUP_ID_INPUT:-${EC2_SECURITY_GROUP_ID_SECRET:-}}" - - if [[ ! "$api_port" =~ ^[0-9]+$ ]]; then - echo "PRODUCTION_ENV.API_PORT must be a numeric port." - exit 1 - fi - - missing=() - for name in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do - if [ -z "${!name}" ]; then - missing+=("$name") - fi - done - if [ -z "$aws_region" ]; then - missing+=("AWS_REGION") - fi - if [ -z "$security_group_id" ]; then - missing+=("EC2_SECURITY_GROUP_ID") - fi - - if [ "${#missing[@]}" -gt 0 ]; then - printf 'Skipping EC2 API ingress sync because optional values are missing: %s\n' "${missing[*]}" - exit 0 - fi - - if ! command -v aws >/dev/null 2>&1; then - python -m pip install --user awscli - export PATH="$HOME/.local/bin:$PATH" - fi - - ip_permissions="IpProtocol=tcp,FromPort=${api_port},ToPort=${api_port},IpRanges=[{CidrIp=0.0.0.0/0,Description=\"SoundLog API for Vercel rewrite\"}]" - set +e - output="$(aws ec2 authorize-security-group-ingress \ - --region "$aws_region" \ - --group-id "$security_group_id" \ - --ip-permissions "$ip_permissions" 2>&1)" - status=$? - set -e - - printf '%s\n' "$output" - if [ "$status" -eq 0 ]; then - echo "Opened TCP ${api_port} on ${security_group_id}." - exit 0 - fi - if grep -q "InvalidPermission.Duplicate" <<< "$output"; then - echo "TCP ${api_port} is already allowed on ${security_group_id}." - exit 0 - fi - - exit "$status" - - - name: Check EC2 API external reachability - shell: bash - env: - EC2_HOST: ${{ secrets.EC2_HOST }} - PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }} - run: | - set -euo pipefail - api_port="$(sed -n 's/^API_PORT=//p' <<< "$PRODUCTION_ENV" | tail -n 1 | tr -d '\r')" - - if [[ ! "$api_port" =~ ^[0-9]+$ ]]; then - echo "PRODUCTION_ENV.API_PORT must be a numeric port." - exit 1 - fi - - if ! curl --fail --silent --show-error --max-time 10 "http://${EC2_HOST}:${api_port}/v1/health" > /tmp/soundlog-external-health.json; then - echo "::error::EC2 API port is not reachable from the GitHub runner. Open TCP ${api_port} on the EC2 security group/firewall or point SOUNDLOG_API_ORIGIN to a reachable API origin before deploying the frontend." - exit 1 - fi - - cat /tmp/soundlog-external-health.json diff --git a/.github/workflows/deploy-gcp.yml b/.github/workflows/deploy-gcp.yml new file mode 100644 index 0000000..2f3657f --- /dev/null +++ b/.github/workflows/deploy-gcp.yml @@ -0,0 +1,300 @@ +name: Deploy API to GCP + +on: + push: + branches: + - main + workflow_dispatch: + inputs: + image_tag: + description: Docker image tag to build and deploy. Defaults to the current commit SHA. + required: false + type: string + +concurrency: + group: deploy-api-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-push: + name: Build and push Docker image + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + image_tag: ${{ steps.meta.outputs.image_tag }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate Docker Hub secrets + shell: bash + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + run: | + set -euo pipefail + missing=() + for name in DOCKERHUB_USERNAME DOCKERHUB_TOKEN; do + if [ -z "${!name}" ]; then + missing+=("$name") + fi + done + + if [ "${#missing[@]}" -gt 0 ]; then + printf 'Missing required secrets: %s\n' "${missing[*]}" + exit 1 + fi + + - name: Set image tag + id: meta + shell: bash + run: | + set -euo pipefail + image_tag="${GITHUB_SHA::12}" + + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.image_tag }}" ]; then + image_tag="${{ inputs.image_tag }}" + fi + + if [[ ! "$image_tag" =~ ^[A-Za-z0-9_.-]{1,128}$ ]]; then + echo "Invalid Docker image tag: ${image_tag}" + exit 1 + fi + + echo "image_tag=${image_tag}" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/soundlog-server:${{ steps.meta.outputs.image_tag }} + ${{ secrets.DOCKERHUB_USERNAME }}/soundlog-server:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + name: Pull and restart on GCP + runs-on: ubuntu-latest + needs: build-and-push + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate deployment secrets + shell: bash + env: + GCP_HOST: ${{ secrets.GCP_HOST }} + GCP_USER: ${{ secrets.GCP_USER }} + GCP_SSH_KEY: ${{ secrets.GCP_SSH_KEY }} + GCP_SSH_PORT: ${{ secrets.GCP_SSH_PORT }} + GCP_APP_DIR: ${{ secrets.GCP_APP_DIR }} + PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }} + run: | + set -euo pipefail + missing=() + for name in GCP_HOST GCP_USER GCP_SSH_KEY GCP_SSH_PORT GCP_APP_DIR PRODUCTION_ENV; do + if [ -z "${!name}" ]; then + missing+=("$name") + fi + done + + for key in CLIENT_URLS POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DB JWT_SECRET UPLOAD_PUBLIC_BASE_URL; do + value="$(sed -n "s/^${key}=//p" <<< "$PRODUCTION_ENV" | tail -n 1)" + if [ -z "$value" ]; then + missing+=("PRODUCTION_ENV.${key}") + fi + done + + if grep -q "^DOCKER_IMAGE=" <<< "$PRODUCTION_ENV"; then + echo "PRODUCTION_ENV must not include DOCKER_IMAGE. The workflow injects it at deploy time." + exit 1 + fi + + if grep -q "^DATABASE_URL=" <<< "$PRODUCTION_ENV"; then + echo "PRODUCTION_ENV must not include DATABASE_URL. The workflow injects an encoded internal database URL at deploy time." + exit 1 + fi + + if [ "${#missing[@]}" -gt 0 ]; then + printf 'Missing required secrets: %s\n' "${missing[*]}" + exit 1 + fi + + - name: Create production env file + shell: bash + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + IMAGE_TAG: ${{ needs.build-and-push.outputs.image_tag }} + PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }} + run: | + set -euo pipefail + mkdir -p .deploy + postgres_user="$(sed -n 's/^POSTGRES_USER=//p' <<< "$PRODUCTION_ENV" | tail -n 1 | tr -d '\r')" + postgres_password="$(sed -n 's/^POSTGRES_PASSWORD=//p' <<< "$PRODUCTION_ENV" | tail -n 1 | tr -d '\r')" + postgres_db="$(sed -n 's/^POSTGRES_DB=//p' <<< "$PRODUCTION_ENV" | tail -n 1 | tr -d '\r')" + + database_url="$( + POSTGRES_USER="$postgres_user" POSTGRES_PASSWORD="$postgres_password" POSTGRES_DB="$postgres_db" node <<'NODE' + const encode = encodeURIComponent; + const user = encode(process.env.POSTGRES_USER || ''); + const password = encode(process.env.POSTGRES_PASSWORD || ''); + const database = encode(process.env.POSTGRES_DB || ''); + process.stdout.write(`postgresql://${user}:${password}@db:5432/${database}?schema=public`); + NODE + )" + + { + printf 'DOCKER_IMAGE=%s/soundlog-server:%s\n' "$DOCKERHUB_USERNAME" "$IMAGE_TAG" + printf '%s\n' "$PRODUCTION_ENV" | sed '/^DOCKER_IMAGE=/d;/^DATABASE_URL=/d' + printf 'DATABASE_URL=%s\n' "$database_url" + } > .deploy/production.env + + - name: Prepare GCP app directory + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.GCP_HOST }} + username: ${{ secrets.GCP_USER }} + key: ${{ secrets.GCP_SSH_KEY }} + port: ${{ secrets.GCP_SSH_PORT }} + script: | + set -eu + mkdir -p "${{ secrets.GCP_APP_DIR }}" + sudo docker --version + sudo docker compose version + + - name: Copy production compose files + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.GCP_HOST }} + username: ${{ secrets.GCP_USER }} + key: ${{ secrets.GCP_SSH_KEY }} + port: ${{ secrets.GCP_SSH_PORT }} + source: docker-compose.prod.yml,Caddyfile + target: ${{ secrets.GCP_APP_DIR }} + overwrite: true + + - name: Copy production env file + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.GCP_HOST }} + username: ${{ secrets.GCP_USER }} + key: ${{ secrets.GCP_SSH_KEY }} + port: ${{ secrets.GCP_SSH_PORT }} + source: .deploy/production.env + target: ${{ secrets.GCP_APP_DIR }} + strip_components: 1 + overwrite: true + + - name: Deploy on GCP + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.GCP_HOST }} + username: ${{ secrets.GCP_USER }} + key: ${{ secrets.GCP_SSH_KEY }} + port: ${{ secrets.GCP_SSH_PORT }} + script: | + set -eu + cd "${{ secrets.GCP_APP_DIR }}" + mv production.env .env + + echo "${{ secrets.DOCKERHUB_TOKEN }}" | sudo docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin + sudo docker compose -f docker-compose.prod.yml pull api + sudo docker compose -f docker-compose.prod.yml up -d + sudo docker compose -f docker-compose.prod.yml ps + + for attempt in $(seq 1 18); do + if sudo docker compose -f docker-compose.prod.yml exec -T api \ + node -e "fetch('http://127.0.0.1:4000/v1/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"; then + sudo docker image prune -f + exit 0 + fi + + echo "Waiting for API health check on GCP (${attempt}/18)..." + sleep 10 + done + + echo "API health check failed on GCP." + sudo docker compose -f docker-compose.prod.yml logs --tail=120 api + exit 1 + + - name: Sync frontend Vercel API origin + shell: bash + env: + FRONTEND_VERCEL_PROJECT: ${{ secrets.FRONTEND_VERCEL_PROJECT }} + FRONTEND_VERCEL_SCOPE: ${{ secrets.FRONTEND_VERCEL_SCOPE }} + FRONTEND_VERCEL_TOKEN: ${{ secrets.FRONTEND_VERCEL_TOKEN }} + run: | + set -euo pipefail + + if [ -z "$FRONTEND_VERCEL_TOKEN" ]; then + echo "Skipping frontend Vercel API origin sync because FRONTEND_VERCEL_TOKEN is not configured." + exit 0 + fi + + api_origin="https://api.soundlog.shop" + project="${FRONTEND_VERCEL_PROJECT:-sound-log-app}" + scope="${FRONTEND_VERCEL_SCOPE:-mannomis-projects}" + tmpdir="$(mktemp -d)" + + cleanup() { + rm -rf "$tmpdir" + } + trap cleanup EXIT + + npx --yes vercel@54.20.1 link \ + --yes \ + --scope "$scope" \ + --project "$project" \ + --cwd "$tmpdir" \ + --token "$FRONTEND_VERCEL_TOKEN" + + for environment in preview production; do + npx --yes vercel@54.20.1 env add SOUNDLOG_API_ORIGIN "$environment" \ + --force \ + --yes \ + --sensitive \ + --value "$api_origin" \ + --cwd "$tmpdir" \ + --scope "$scope" \ + --token "$FRONTEND_VERCEL_TOKEN" + done + + - name: Verify GCP API contract + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.GCP_HOST }} + username: ${{ secrets.GCP_USER }} + key: ${{ secrets.GCP_SSH_KEY }} + port: ${{ secrets.GCP_SSH_PORT }} + script: | + set -eu + cd "${{ secrets.GCP_APP_DIR }}" + sudo docker compose -f docker-compose.prod.yml exec -T \ + -e PUBLIC_API_BASE_URL=http://127.0.0.1:4000 \ + api \ + node scripts/check-public-api-contract.mjs + + - name: Check public HTTPS reachability + shell: bash + run: | + set -eu + if curl --fail --silent --show-error --max-time 10 "https://api.soundlog.shop/v1/health" > /tmp/soundlog-external-health.json; then + cat /tmp/soundlog-external-health.json + exit 0 + fi + + echo "::warning::https://api.soundlog.shop is not reachable yet. This is expected until the api.soundlog.shop DNS A record points at the GCP static IP and Caddy has issued a TLS certificate. The deploy itself succeeded; this check is informational only." diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..e076d73 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,3 @@ +api.soundlog.shop { + reverse_proxy api:4000 +} diff --git a/README.md b/README.md index 65aacbb..be88593 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=http://localhost:4000 npm run web ``` 웹 기본 주소는 `http://localhost:8081`입니다. -배포된 앱과 웹은 현재 Vercel의 `/api/soundlog` 프록시를 통해 EC2 API를 호출합니다. +배포된 앱은 `https://api.soundlog.shop`을 직접 호출하고, 배포된 웹은 Vercel의 `/api/soundlog` 프록시를 통해 같은 GCP API를 호출합니다. 자세한 내용은 [`docs/gcp-deployment.md`](docs/gcp-deployment.md)를 참고합니다. ## API Docs @@ -84,7 +84,7 @@ EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=http://localhost:4000 npm run web - Swagger UI: `http://localhost:4000/docs` - OpenAPI YAML: `http://localhost:4000/openapi.yaml` -- 운영 API 프록시: `https://soundlog.shop/api/soundlog` +- 운영 API: `https://api.soundlog.shop` Swagger에서 바로 DB 쓰기를 확인할 때는 인증 없이 호출 가능한 개발용 API를 사용할 수 있습니다. @@ -99,7 +99,7 @@ Swagger에서 바로 DB 쓰기를 확인할 때는 인증 없이 호출 가능 - `ALLOW_DEV_AUTH_FALLBACK=false` - 자체 이메일/비밀번호 로그인만 사용하며, 서버는 비밀번호 원문 대신 bcrypt hash만 저장 - `CLIENT_URLS`, `UPLOAD_PUBLIC_BASE_URL`, 앱의 `EXPO_PUBLIC_SOUNDLOG_API_BASE_URL`은 HTTPS 도메인 사용 -- 운영 기준 frontend origin은 `https://soundlog.shop`입니다. 공개 API URL은 `https://soundlog.shop/api/soundlog`이며, 별도 `api` 서브도메인은 사용하지 않습니다. +- 운영 기준 frontend origin은 `https://soundlog.shop`입니다. 공개 API URL은 `https://api.soundlog.shop`이며, GCP VM 위의 Caddy가 TLS를 직접 종료합니다. - `REQUEST_BODY_LIMIT`, `MOMENT_PHOTO_MAX_FILE_SIZE_MB`, `UPLOAD_DIRECTORY`, `UPLOAD_PUBLIC_PATH`는 운영 파일 업로드 정책에 맞게 조정 - iOS 앱 설정에 전체 ATS 예외를 넣지 않기 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 025a4fc..ef0eac3 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -41,8 +41,6 @@ services: UPLOAD_PUBLIC_BASE_URL: ${UPLOAD_PUBLIC_BASE_URL} UPLOAD_PUBLIC_PATH: ${UPLOAD_PUBLIC_PATH:-/uploads} USE_MOCK_DB: ${USE_MOCK_DB:-false} - ports: - - "${API_PORT:-4000}:4000" volumes: - uploads_data:/app/uploads healthcheck: @@ -56,6 +54,21 @@ services: retries: 3 start_period: 30s + caddy: + image: caddy:2-alpine + restart: unless-stopped + depends_on: + - api + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + volumes: postgres_data: uploads_data: + caddy_data: + caddy_config: diff --git a/docs/ec2-deployment.md b/docs/ec2-deployment.md deleted file mode 100644 index f08bd80..0000000 --- a/docs/ec2-deployment.md +++ /dev/null @@ -1,123 +0,0 @@ -# EC2 deployment - -GitHub Actions builds the API image, pushes it to Docker Hub, then connects to EC2 and restarts the server with `docker compose`. - -## Required secrets - -Only workflow-level credentials stay as separate GitHub Secrets. Application environment values are grouped into one multiline `.env` secret. - -| Secret | Example | Description | -| --- | --- | --- | -| `DOCKERHUB_USERNAME` | `soundlogteam` | Docker Hub namespace. | -| `DOCKERHUB_TOKEN` | `dckr_pat_...` | Docker Hub access token with push/pull permission. | -| `EC2_HOST` | `54.226.62.131` | EC2 public IP or domain. | -| `EC2_USER` | `ec2-user` | SSH user. | -| `EC2_SSH_PORT` | `22` | SSH port. | -| `EC2_SSH_KEY` | PEM private key | Private key that can SSH into EC2. | -| `EC2_APP_DIR` | `/home/ec2-user/soundlog-server` | Directory where compose and `.env` are written. | -| `PRODUCTION_ENV` | multiline `.env` | Server runtime environment except generated deployment values. | -| `FRONTEND_VERCEL_TOKEN` | `vercel_...` | Optional. Enables automatic `SOUNDLOG_API_ORIGIN` sync for the frontend Vercel project. | -| `FRONTEND_VERCEL_SCOPE` | `mannomis-projects` | Optional. Vercel team/user scope for the frontend project. Defaults to `mannomis-projects`. | -| `FRONTEND_VERCEL_PROJECT` | `sound-log-app` | Optional. Frontend Vercel project name. Defaults to `sound-log-app`. | -| `AWS_ACCESS_KEY_ID` | `AKIA...` | Optional. Enables automatic EC2 API security-group ingress sync. | -| `AWS_SECRET_ACCESS_KEY` | `...` | Optional. Enables automatic EC2 API security-group ingress sync. | -| `AWS_SESSION_TOKEN` | `...` | Optional. Use only for temporary AWS credentials. | -| `AWS_REGION` | `us-east-1` | Optional. AWS region for the EC2 security group. Can also be provided as workflow dispatch input. | -| `EC2_SECURITY_GROUP_ID` | `sg-...` | Optional. Security group that should allow inbound `API_PORT`. Can also be provided as workflow dispatch input. | - -## `PRODUCTION_ENV` format - -Register `PRODUCTION_ENV` as a multiline secret with this format. - -```dotenv -API_PORT=4000 -CLIENT_URL=https://soundlog.shop -CLIENT_URLS=https://soundlog.shop,https://www.soundlog.shop,https://sound-log-app.vercel.app,http://localhost:8081,http://localhost:8082 -POSTGRES_USER=soundlog -POSTGRES_PASSWORD= -POSTGRES_DB=soundlog -JWT_SECRET= -JWT_EXPIRES_IN_SECONDS=3600 -ML_RECOMMENDATION_API_URL=http://211.188.54.204:8000/recommend -ML_RECOMMENDATION_TIMEOUT_MS=5000 -REQUEST_BODY_LIMIT=1mb -MOMENT_PHOTO_MAX_FILE_SIZE_MB=10 -REVERSE_GEOCODING_BASE_URL=https://nominatim.openstreetmap.org -REVERSE_GEOCODING_USER_AGENT=Soundlog/0.1 (+https://github.com/SoundLogTeam/SoundLogServer) -TOUR_API_BASE_URL=https://apis.data.go.kr/B551011/KorService2 -TOUR_API_SERVICE_KEY= -ALLOW_DEV_AUTH_FALLBACK=false -UPLOAD_PUBLIC_PATH=/uploads -USE_MOCK_DB=false -``` - -The workflow prepends `DOCKER_IMAGE=/soundlog-server:`, derives `UPLOAD_PUBLIC_BASE_URL=http://:`, and injects a URL-encoded internal `DATABASE_URL` at deploy time, so do not include those values in `PRODUCTION_ENV`. - -## `soundlog.shop` DNS and API proxy - -Use this DNS layout. - -| Host | Type | Target | -| --- | --- | --- | -| `soundlog.shop` | `A` | `76.76.21.21` | -| `www.soundlog.shop` | `CNAME` | `cname.vercel-dns.com.` | - -Do not use a separate API subdomain for the current deployment. The public API URL is: - -```txt -https://soundlog.shop/api/soundlog -``` - -The frontend Vercel project rewrites `/api/soundlog/:path*` to the EC2 API origin in the server-side Vercel layer. Set Vercel `SOUNDLOG_API_ORIGIN` to the current EC2 API origin, for example `http://:4000`. - -When `FRONTEND_VERCEL_TOKEN` is configured in this repo, the deploy workflow updates `SOUNDLOG_API_ORIGIN` for the frontend Vercel `preview` and `production` environments after each successful EC2 deploy. Re-run the frontend Vercel deployment after the env sync so the generated rewrite config is rebuilt. - -## Public API ingress - -The deploy workflow publishes the API container on `0.0.0.0:`, but Vercel can only proxy `/api/soundlog` when the EC2 security group also allows inbound TCP ``. If the deploy log fails at `Check EC2 API external reachability`, use the `security-group-ids=` value printed in `Diagnose EC2 API network` and open the API port. - -```sh -aws ec2 authorize-security-group-ingress \ - --region \ - --group-id \ - --ip-permissions 'IpProtocol=tcp,FromPort=4000,ToPort=4000,IpRanges=[{CidrIp=0.0.0.0/0,Description="SoundLog API for Vercel rewrite"}]' -``` - -The deploy log also prints `placement-region=` and `security-group-ids=` in `Diagnose EC2 API network`, so those values can be copied directly into the command. If `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, and `EC2_SECURITY_GROUP_ID` are configured as repository secrets, the deploy workflow tries this security-group sync automatically before the external reachability gate. If the rule already exists, AWS returns `InvalidPermission.Duplicate`; the workflow treats that as success and continues. - -## Register secrets with GitHub CLI - -Run these from any directory after `gh auth login`. - -```sh -gh secret set DOCKERHUB_USERNAME --repo SoundLogTeam/SoundLogServer --body '' -gh secret set DOCKERHUB_TOKEN --repo SoundLogTeam/SoundLogServer --body '' - -gh secret set EC2_HOST --repo SoundLogTeam/SoundLogServer --body '54.226.62.131' -gh secret set EC2_USER --repo SoundLogTeam/SoundLogServer --body 'ec2-user' -gh secret set EC2_SSH_PORT --repo SoundLogTeam/SoundLogServer --body '22' -gh secret set EC2_SSH_KEY --repo SoundLogTeam/SoundLogServer < ~/.ssh/soundlog-ec2.pem -gh secret set EC2_APP_DIR --repo SoundLogTeam/SoundLogServer --body '/home/ec2-user/soundlog-server' -gh secret set PRODUCTION_ENV --repo SoundLogTeam/SoundLogServer < .env.production - -gh secret set FRONTEND_VERCEL_TOKEN --repo SoundLogTeam/SoundLogServer --body '' -gh secret set FRONTEND_VERCEL_SCOPE --repo SoundLogTeam/SoundLogServer --body 'mannomis-projects' -gh secret set FRONTEND_VERCEL_PROJECT --repo SoundLogTeam/SoundLogServer --body 'sound-log-app' - -gh secret set AWS_ACCESS_KEY_ID --repo SoundLogTeam/SoundLogServer --body '' -gh secret set AWS_SECRET_ACCESS_KEY --repo SoundLogTeam/SoundLogServer --body '' -gh secret set AWS_REGION --repo SoundLogTeam/SoundLogServer --body '' -gh secret set EC2_SECURITY_GROUP_ID --repo SoundLogTeam/SoundLogServer --body '' -``` - -## Run deployment - -The workflow runs automatically after a push to a configured deployment branch. You can also deploy manually from GitHub Actions or CLI. - -```sh -gh workflow run deploy-ec2.yml --repo SoundLogTeam/SoundLogServer -``` - -EC2 must already have Docker Engine and Docker Compose v2 installed. The workflow keeps Postgres data in the `postgres_data` Docker volume and uploaded files in the `uploads_data` Docker volume. - -After deployment, the API container runs Prisma migrations and upserts the public music catalog used by the frontend, including seeded tracks, playlists, mood recommendations, and regional trends. It does not seed local/test tourist-place fixtures into production. The workflow then verifies `http://127.0.0.1:/v1/health` from inside EC2 and runs the API contract check from inside the deployed API container. It prints EC2 network diagnostics, including Docker port publishing, listening sockets, local health, public-IP self-curl, EC2 metadata network identity, placement region, security groups, and host firewall state. When optional AWS credentials are configured, it opens TCP `` on the configured security group. Finally, it verifies `http://:/v1/health` from GitHub Actions and fails the deploy if the public API port is unreachable. If this gate fails, open TCP `` on the reported EC2 security group/firewall or point `SOUNDLOG_API_ORIGIN` to a reachable API origin before rebuilding the frontend. After the frontend deployment is rebuilt with the synced Vercel env, verify `https://soundlog.shop/api/soundlog/v1/health` and run the app repo's deployed-web check. The app check logs in to protected APIs; set `SOUNDLOG_CHECK_EMAIL` and `SOUNDLOG_CHECK_PASSWORD` to reuse a smoke account, or omit them to let the script create a temporary `@soundlog.test` account. diff --git a/docs/gcp-deployment.md b/docs/gcp-deployment.md new file mode 100644 index 0000000..2968c19 --- /dev/null +++ b/docs/gcp-deployment.md @@ -0,0 +1,116 @@ +# GCP deployment + +GitHub Actions builds the API image, pushes it to Docker Hub, then connects to a GCP Compute Engine VM and restarts the server with `docker compose`. Postgres and the API run as containers on the VM, exactly like the previous EC2 setup. A `caddy` container in front of the API terminates HTTPS for `api.soundlog.shop` with an automatically issued Let's Encrypt certificate, so the API no longer needs a Vercel proxy for TLS. + +## GCP resources + +| Resource | Value | +| --- | --- | +| Project | `nomi-app-deploy-2026` | +| Zone | `asia-northeast3-a` | +| VM | `soundlog-api` (`e2-medium`, Debian 12) | +| Static IP | reserved as `soundlog-api-ip` in `asia-northeast3` | +| Firewall | `soundlog-api-allow-ssh-http-https` allows tcp 22/80/443 to the `soundlog-api` tag | + +The VM's startup script installs Docker Engine + the Compose plugin and creates `/home/deploy/soundlog-server`. No GCP service account or key is used — the deploy workflow only needs SSH access to the VM's static IP, the same model the EC2 workflow used. + +## Required secrets + +Only workflow-level credentials stay as separate GitHub Secrets. Application environment values are grouped into one multiline `.env` secret. + +| Secret | Example | Description | +| --- | --- | --- | +| `DOCKERHUB_USERNAME` | `soundlogteam` | Docker Hub namespace. | +| `DOCKERHUB_TOKEN` | `dckr_pat_...` | Docker Hub access token with push/pull permission. | +| `GCP_HOST` | `34.64.116.40` | GCP VM static external IP. | +| `GCP_USER` | `deploy` | SSH user provisioned on the VM. | +| `GCP_SSH_PORT` | `22` | SSH port. | +| `GCP_SSH_KEY` | private key | Private key for the dedicated `deploy` SSH keypair (not a personal key). | +| `GCP_APP_DIR` | `/home/deploy/soundlog-server` | Directory where compose files and `.env` are written. | +| `PRODUCTION_ENV` | multiline `.env` | Server runtime environment except generated deployment values. | +| `FRONTEND_VERCEL_TOKEN` | `vercel_...` | Optional. Enables automatic `SOUNDLOG_API_ORIGIN` sync for the frontend Vercel project. | +| `FRONTEND_VERCEL_SCOPE` | `mannomis-projects` | Optional. Vercel team/user scope for the frontend project. Defaults to `mannomis-projects`. | +| `FRONTEND_VERCEL_PROJECT` | `sound-log-app` | Optional. Frontend Vercel project name. Defaults to `sound-log-app`. | + +## `PRODUCTION_ENV` format + +Register `PRODUCTION_ENV` as a multiline secret with this format. + +```dotenv +CLIENT_URL=https://soundlog.shop +CLIENT_URLS=https://soundlog.shop,https://www.soundlog.shop,https://sound-log-app.vercel.app,http://localhost:8081,http://localhost:8082 +POSTGRES_USER=soundlog +POSTGRES_PASSWORD= +POSTGRES_DB=soundlog +JWT_SECRET= +JWT_EXPIRES_IN_SECONDS=3600 +ML_RECOMMENDATION_API_URL=http://211.188.54.204:8000/recommend +ML_RECOMMENDATION_TIMEOUT_MS=5000 +REQUEST_BODY_LIMIT=1mb +MOMENT_PHOTO_MAX_FILE_SIZE_MB=10 +REVERSE_GEOCODING_BASE_URL=https://nominatim.openstreetmap.org +REVERSE_GEOCODING_USER_AGENT=Soundlog/0.1 (+https://github.com/SoundLogTeam/SoundLogServer) +TOUR_API_BASE_URL=https://apis.data.go.kr/B551011/KorService2 +TOUR_API_SERVICE_KEY= +ALLOW_DEV_AUTH_FALLBACK=false +UPLOAD_PUBLIC_BASE_URL=https://api.soundlog.shop +UPLOAD_PUBLIC_PATH=/uploads +USE_MOCK_DB=false +``` + +Unlike the EC2 workflow, `UPLOAD_PUBLIC_BASE_URL` is a fixed HTTPS value here since Caddy always terminates TLS at the same domain — the workflow no longer derives it from a host:port pair. The workflow prepends `DOCKER_IMAGE=/soundlog-server:` and injects a URL-encoded internal `DATABASE_URL` at deploy time, so do not include those values in `PRODUCTION_ENV`. + +`TOUR_API_SERVICE_KEY` ships blank; set the real data.go.kr key if the tour-recommendation feature needs to work in production. + +## `api.soundlog.shop` DNS + +Add this record in **Gabia DNS** (the registrar used for `soundlog.shop`, see `SOUNDLOG_SHOP_DOMAIN.md` in the SoundLogApp repo): + +| Type | Host | Value | +| --- | --- | --- | +| `A` | `api` | `34.64.116.40` | + +Caddy cannot issue a Let's Encrypt certificate for `api.soundlog.shop` until this record resolves. Until then, `https://api.soundlog.shop` will fail and the deploy workflow's final reachability check is expected to warn (non-blocking). + +## Register secrets with GitHub CLI + +Run these from any directory after `gh auth login`. + +```sh +gh secret set DOCKERHUB_USERNAME --repo SoundLogTeam/SoundLogServer --body '' +gh secret set DOCKERHUB_TOKEN --repo SoundLogTeam/SoundLogServer --body '' + +gh secret set GCP_HOST --repo SoundLogTeam/SoundLogServer --body '34.64.116.40' +gh secret set GCP_USER --repo SoundLogTeam/SoundLogServer --body 'deploy' +gh secret set GCP_SSH_PORT --repo SoundLogTeam/SoundLogServer --body '22' +gh secret set GCP_SSH_KEY --repo SoundLogTeam/SoundLogServer < ~/.ssh/soundlog-gcp-deploy +gh secret set GCP_APP_DIR --repo SoundLogTeam/SoundLogServer --body '/home/deploy/soundlog-server' +gh secret set PRODUCTION_ENV --repo SoundLogTeam/SoundLogServer < .env.production + +gh secret set FRONTEND_VERCEL_TOKEN --repo SoundLogTeam/SoundLogServer --body '' +gh secret set FRONTEND_VERCEL_SCOPE --repo SoundLogTeam/SoundLogServer --body 'mannomis-projects' +gh secret set FRONTEND_VERCEL_PROJECT --repo SoundLogTeam/SoundLogServer --body 'sound-log-app' +``` + +## Run deployment + +The workflow runs automatically after a push to `main`. You can also deploy manually from GitHub Actions or CLI. + +```sh +gh workflow run deploy-gcp.yml --repo SoundLogTeam/SoundLogServer +``` + +After deployment, the API container runs Prisma migrations and upserts the public music catalog used by the frontend. The workflow verifies API health from inside the `api` container over SSH and runs the API contract check from inside the deployed container. It then checks `https://api.soundlog.shop/v1/health` from GitHub Actions; this step is informational until the DNS record above is in place. + +## Verification + +```sh +dig +short api.soundlog.shop A +curl https://api.soundlog.shop/v1/health +``` + +Run from the SoundLogApp repo to validate the full contract against the live origin: + +```sh +SOUNDLOG_API_ORIGIN=https://api.soundlog.shop npm run check:api-origin +```