feat(landing): i18n via next-intl + Caddy locale root redirects (#99) #57
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 to EC2 | |
| on: | |
| push: | |
| branches: [main] | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| packages: write | |
| env: | |
| REGISTRY: ghcr.io | |
| LANDING_IMAGE: ghcr.io/overthelex/merged-landing | |
| PORTAL_IMAGE: ghcr.io/overthelex/merged-portal | |
| EC2_HOST: 18.196.127.148 | |
| EC2_USER: ubuntu | |
| SSH_KEY: ~/.ssh/merged-key.pem | |
| jobs: | |
| build: | |
| if: "!contains(github.event.head_commit.message, 'chore: bump version')" | |
| runs-on: [self-hosted, merged] | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Login to GHCR | |
| run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin | |
| - name: Build and push landing image | |
| run: | | |
| docker build \ | |
| -t $LANDING_IMAGE:${{ github.sha }} \ | |
| -t $LANDING_IMAGE:latest \ | |
| -f apps/landing/Dockerfile . | |
| docker push $LANDING_IMAGE:${{ github.sha }} | |
| docker push $LANDING_IMAGE:latest | |
| - name: Build and push portal image | |
| run: | | |
| docker build \ | |
| -t $PORTAL_IMAGE:${{ github.sha }} \ | |
| -t $PORTAL_IMAGE:latest \ | |
| -f apps/portal/Dockerfile . | |
| docker push $PORTAL_IMAGE:${{ github.sha }} | |
| docker push $PORTAL_IMAGE:latest | |
| deploy: | |
| needs: build | |
| runs-on: [self-hosted, merged] | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Sync config + migrations to EC2 | |
| run: | | |
| ssh -o IdentitiesOnly=yes -i $SSH_KEY -o StrictHostKeyChecking=no $EC2_USER@$EC2_HOST \ | |
| 'mkdir -p /home/ubuntu/merged /home/ubuntu/merged/secrets /home/ubuntu/merged/migrations' | |
| scp -o IdentitiesOnly=yes -i $SSH_KEY -o StrictHostKeyChecking=no \ | |
| docker-compose.prod.yml $EC2_USER@$EC2_HOST:/home/ubuntu/merged/docker-compose.prod.yml | |
| scp -o IdentitiesOnly=yes -i $SSH_KEY -o StrictHostKeyChecking=no \ | |
| infra/Caddyfile $EC2_USER@$EC2_HOST:/home/ubuntu/merged/Caddyfile | |
| ssh -o IdentitiesOnly=yes -i $SSH_KEY -o StrictHostKeyChecking=no $EC2_USER@$EC2_HOST \ | |
| 'rm -rf /home/ubuntu/merged/migrations/* /home/ubuntu/merged/migrations/.* 2>/dev/null; true' | |
| scp -o IdentitiesOnly=yes -i $SSH_KEY -o StrictHostKeyChecking=no \ | |
| -r packages/db/migrations/. $EC2_USER@$EC2_HOST:/home/ubuntu/merged/migrations/ | |
| - name: Ensure secrets + env on EC2 | |
| run: | | |
| ssh -o IdentitiesOnly=yes -i $SSH_KEY -o StrictHostKeyChecking=no $EC2_USER@$EC2_HOST << EOF | |
| set -euo pipefail | |
| cd /home/ubuntu/merged | |
| if [ ! -f secrets/postgres_password ]; then | |
| head -c 24 /dev/urandom | base64 | tr -d '=+/' > secrets/postgres_password | |
| chmod 600 secrets/postgres_password | |
| fi | |
| PG_PW=\$(cat secrets/postgres_password) | |
| cat > .env.portal <<ENV | |
| NODE_ENV=production | |
| DATABASE_URL=postgres://merged:\${PG_PW}@merged-postgres:5432/merged | |
| PUBLIC_BASE_URL=https://merged.com.ua | |
| PORTAL_URL=https://portal.merged.com.ua | |
| APP_VERSION=${{ github.sha }} | |
| AUTH_URL=https://portal.merged.com.ua | |
| AUTH_TRUST_HOST=true | |
| AUTH_SECRET=${{ secrets.AUTH_SECRET }} | |
| AUTH_GOOGLE_ID=${{ secrets.AUTH_GOOGLE_ID }} | |
| AUTH_GOOGLE_SECRET=${{ secrets.AUTH_GOOGLE_SECRET }} | |
| GITHUB_APP_ID=${{ secrets.GH_APP_ID }} | |
| GITHUB_APP_PRIVATE_KEY=${{ secrets.GH_APP_PRIVATE_KEY }} | |
| GITHUB_APP_INSTALLATION_ID=${{ secrets.GH_APP_INSTALLATION_ID }} | |
| GITHUB_WEBHOOK_SECRET=${{ secrets.GH_WEBHOOK_SECRET }} | |
| TASKS_ORG=imerged | |
| # mail.legal.org.ua and mail.merged.com.ua both resolve to the | |
| # shared mail MTA (178.162.234.145). We use mail.legal.org.ua here | |
| # because the MTA's TLS cert advertises it as a SAN while a | |
| # merged-specific SAN isn't wired into postfix SNI yet. Relay | |
| # still works because merged-prod is in the MTA's mynetworks and | |
| # OpenDKIM signs by sender domain, not HELO. | |
| SMTP_HOST=mail.legal.org.ua | |
| SMTP_PORT=587 | |
| SMTP_SECURE=false | |
| EMAIL_FROM=merged <no-reply@merged.com.ua> | |
| EMAIL_BRAND_URL=https://merged.com.ua | |
| EMAIL_LOGO_URL=https://portal.merged.com.ua/brand/logo-ink-128.png | |
| ENV | |
| chmod 600 .env.portal | |
| EOF | |
| - name: Start postgres + pull images | |
| run: | | |
| ssh -o IdentitiesOnly=yes -i $SSH_KEY -o StrictHostKeyChecking=no $EC2_USER@$EC2_HOST << 'EOF' | |
| set -euo pipefail | |
| cd /home/ubuntu/merged | |
| if systemctl is-active --quiet caddy; then | |
| sudo systemctl stop caddy | |
| sudo systemctl disable caddy | |
| fi | |
| echo "${{ secrets.GITHUB_TOKEN }}" | sudo docker login ghcr.io -u ${{ github.actor }} --password-stdin | |
| sudo docker pull ghcr.io/overthelex/merged-landing:latest | |
| sudo docker pull ghcr.io/overthelex/merged-portal:latest | |
| # Bring up postgres first so migrations can run against it | |
| sudo docker compose -f docker-compose.prod.yml up -d postgres | |
| # Wait for healthy (max 60s) | |
| for i in $(seq 1 30); do | |
| if sudo docker exec merged-postgres pg_isready -U merged -d merged >/dev/null 2>&1; then | |
| echo "postgres ready" | |
| break | |
| fi | |
| sleep 2 | |
| done | |
| EOF | |
| - name: Run drizzle migrations | |
| run: | | |
| ssh -o IdentitiesOnly=yes -i $SSH_KEY -o StrictHostKeyChecking=no $EC2_USER@$EC2_HOST << 'EOF' | |
| set -euo pipefail | |
| cd /home/ubuntu/merged | |
| PG_PW=$(cat secrets/postgres_password) | |
| # Use the merged_default network so the ephemeral node container can reach merged-postgres by name | |
| sudo docker run --rm \ | |
| --network merged_default \ | |
| -v "$PWD/migrations":/migrations:ro \ | |
| -e DATABASE_URL="postgres://merged:${PG_PW}@merged-postgres:5432/merged" \ | |
| node:20-alpine sh -c ' | |
| cd /tmp && mkdir app && cd app | |
| npm init -y >/dev/null | |
| npm i --silent drizzle-orm@0.36.4 postgres@3.4.5 >/dev/null | |
| node -e " | |
| const postgres = require(\"postgres\"); | |
| const { drizzle } = require(\"drizzle-orm/postgres-js\"); | |
| const { migrate } = require(\"drizzle-orm/postgres-js/migrator\"); | |
| (async () => { | |
| const client = postgres(process.env.DATABASE_URL, { max: 1 }); | |
| const db = drizzle(client); | |
| await migrate(db, { migrationsFolder: \"/migrations\" }); | |
| await client.end(); | |
| console.log(\"migrations applied\"); | |
| })().catch(e => { console.error(e); process.exit(1); }); | |
| " | |
| ' | |
| EOF | |
| - name: Bring up full stack | |
| run: | | |
| ssh -o IdentitiesOnly=yes -i $SSH_KEY -o StrictHostKeyChecking=no $EC2_USER@$EC2_HOST << 'EOF' | |
| set -euo pipefail | |
| cd /home/ubuntu/merged | |
| sudo docker compose -f docker-compose.prod.yml up -d | |
| sudo docker image prune -f | |
| EOF | |
| - name: Health check | |
| run: | | |
| sleep 15 | |
| ssh -o IdentitiesOnly=yes -i $SSH_KEY -o StrictHostKeyChecking=no $EC2_USER@$EC2_HOST \ | |
| 'sudo docker exec merged-landing wget -qO- http://127.0.0.1:8080/ | grep -qi "merged" && echo "landing healthy"' | |
| ssh -o IdentitiesOnly=yes -i $SSH_KEY -o StrictHostKeyChecking=no $EC2_USER@$EC2_HOST \ | |
| 'sudo docker exec merged-portal wget -qO- http://127.0.0.1:3000/api/health | grep -q "\"status\":\"ok\"" && echo "portal healthy"' | |
| curl -sSf https://merged.com.ua/ | grep -qi "технічний скринінг" && echo "landing public endpoint healthy" | |
| curl -sSf https://portal.merged.com.ua/api/health | grep -q '"status":"ok"' && echo "portal public endpoint healthy" |