name: CI/CD → Deploy via SSH on: push: branches: - main env: REGISTRY: ${{ vars.REGISTRY }} BACKEND_IMAGE: saas-imobiliaria-backend FRONTEND_IMAGE: saas-imobiliaria-frontend jobs: # ──────────────────────────────────────────────────────────────────────────── # 1. BUILD & PUSH IMAGES (Docker-in-Docker) # ──────────────────────────────────────────────────────────────────────────── build: name: Build & Push Docker Images runs-on: docker container: image: node:20-alpine options: --privileged steps: - name: Install git run: apk add --no-cache git docker-cli - name: Checkout uses: actions/checkout@v4 - name: Set image tag id: tag run: echo "IMAGE_TAG=$(echo $GITHUB_SHA | cut -c1-8)" >> $GITHUB_OUTPUT - name: Log in to registry run: | echo "${{ secrets.REGISTRY_PASSWORD }}" | \ docker login ${{ vars.REGISTRY }} -u ${{ secrets.REGISTRY_USER }} --password-stdin - name: Build & push backend run: | TAG=${{ steps.tag.outputs.IMAGE_TAG }} docker build \ -f backend/Dockerfile.prod \ -t ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:${TAG} \ -t ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:latest \ ./backend docker push ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:${TAG} docker push ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:latest - name: Build & push frontend run: | TAG=${{ steps.tag.outputs.IMAGE_TAG }} docker build \ -f frontend/Dockerfile.prod \ --build-arg VITE_API_URL=https://${{ vars.DOMAIN }}/api \ -t ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:${TAG} \ -t ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:latest \ ./frontend docker push ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:${TAG} docker push ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:latest outputs: image_tag: ${{ steps.tag.outputs.IMAGE_TAG }} # ──────────────────────────────────────────────────────────────────────────── # 2. DEPLOY VIA SSH # ──────────────────────────────────────────────────────────────────────────── deploy: name: Deploy via SSH runs-on: docker needs: build container: image: node:20-alpine steps: - name: Install SSH tools run: apk add --no-cache openssh-client - name: Checkout uses: actions/checkout@v4 - name: Setup SSH key run: | mkdir -p ~/.ssh printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key chmod 600 ~/.ssh/deploy_key ssh-keyscan -p ${{ vars.SSH_PORT }} ${{ vars.SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null - name: Copy compose file to server run: | ssh -i ~/.ssh/deploy_key -p ${{ vars.SSH_PORT }} \ ${{ vars.SSH_USER }}@${{ vars.SSH_HOST }} \ "mkdir -p /opt/saas-imobiliaria" scp -i ~/.ssh/deploy_key -P ${{ vars.SSH_PORT }} \ docker-compose.prod.yml \ ${{ vars.SSH_USER }}@${{ vars.SSH_HOST }}:/opt/saas-imobiliaria/docker-compose.prod.yml - name: Sync property images to server run: | ssh -i ~/.ssh/deploy_key -p ${{ vars.SSH_PORT }} \ ${{ vars.SSH_USER }}@${{ vars.SSH_HOST }} \ "mkdir -p /opt/saas-imobiliaria/public/imoveis" scp -i ~/.ssh/deploy_key -P ${{ vars.SSH_PORT }} -r \ frontend/public/imoveis \ ${{ vars.SSH_USER }}@${{ vars.SSH_HOST }}:/opt/saas-imobiliaria/public/ - name: Deploy Swarm stack on server env: IMAGE_TAG: ${{ needs.build.outputs.image_tag }} run: | ssh -i ~/.ssh/deploy_key -p ${{ vars.SSH_PORT }} \ ${{ vars.SSH_USER }}@${{ vars.SSH_HOST }} \ "set -e mkdir -p /opt/saas-imobiliaria cd /opt/saas-imobiliaria echo '${{ secrets.REGISTRY_PASSWORD }}' | \ docker login ${{ vars.REGISTRY }} -u ${{ secrets.REGISTRY_USER }} --password-stdin docker pull ${{ vars.REGISTRY }}/saas-imobiliaria-backend:${IMAGE_TAG} docker pull ${{ vars.REGISTRY }}/saas-imobiliaria-frontend:${IMAGE_TAG} IMAGE_TAG=${IMAGE_TAG} \ REGISTRY=${{ vars.REGISTRY }} \ DOMAIN=${{ vars.DOMAIN }} \ POSTGRES_DB=${{ secrets.POSTGRES_DB }} \ POSTGRES_USER=${{ secrets.POSTGRES_USER }} \ POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }} \ SECRET_KEY=${{ secrets.SECRET_KEY }} \ JWT_SECRET_KEY=${{ secrets.JWT_SECRET_KEY }} \ docker stack deploy \ --compose-file docker-compose.prod.yml \ --with-registry-auth \ --prune \ saas-imobiliaria sleep 15 docker stack services saas-imobiliaria" # ──────────────────────────────────────────────────────────────────────────── # 3. HEALTH CHECK # ──────────────────────────────────────────────────────────────────────────── healthcheck: name: Validate HTTPS & Endpoints runs-on: docker needs: [build, deploy] container: image: node:20-alpine steps: - name: Install tools run: apk add --no-cache curl openssl - name: Wait for stack to stabilize run: sleep 40 - name: Frontend HTTPS run: | S=$(curl -s -o /dev/null -w "%{http_code}" --max-time 15 --resolve "${{ vars.DOMAIN }}:443:${{ vars.SSH_HOST }}" "https://${{ vars.DOMAIN }}") echo "Frontend: $S" [ "$S" = "200" ] || (echo "❌ Frontend falhou ($S)" && exit 1) - name: HTTP → HTTPS redirect run: | L=$(curl -s -o /dev/null -w "%{redirect_url}" --max-time 10 --resolve "${{ vars.DOMAIN }}:80:${{ vars.SSH_HOST }}" "http://${{ vars.DOMAIN }}") echo "Redirect: $L" echo "$L" | grep -q "https://" || (echo "❌ Redirect ausente" && exit 1) - name: Backend /api/health run: | R=$(curl -s --max-time 15 --resolve "${{ vars.DOMAIN }}:443:${{ vars.SSH_HOST }}" "https://${{ vars.DOMAIN }}/health") S=$(curl -s -o /dev/null -w "%{http_code}" --max-time 15 --resolve "${{ vars.DOMAIN }}:443:${{ vars.SSH_HOST }}" "https://${{ vars.DOMAIN }}/health") echo "Health: $S → $R" [ "$S" = "200" ] || (echo "❌ Health falhou ($S)" && exit 1) echo "$R" | grep -q '"db": "ok"' || (echo "❌ DB não conectado" && exit 1) - name: TLS certificate validity run: | EXPIRY=$(echo | openssl s_client -connect ${{ vars.SSH_HOST }}:443 \ -servername ${{ vars.DOMAIN }} 2>/dev/null \ | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2) echo "Expira: $EXPIRY" # Alpine usa busybox date — converte via openssl diretamente DAYS=$(echo | openssl s_client -connect ${{ vars.SSH_HOST }}:443 \ -servername ${{ vars.DOMAIN }} 2>/dev/null \ | openssl x509 -noout -checkend 604800 2>/dev/null; echo $?) # checkend retorna 0 se válido por mais de N segundos (604800 = 7 dias) [ "$DAYS" = "0" ] || (echo "❌ Cert expira em menos de 7 dias!" && exit 1) echo "✅ Certificado válido por mais de 7 dias (expira: $EXPIRY)" - name: GET /api/v1/properties run: | S=$(curl -s -o /dev/null -w "%{http_code}" --max-time 15 --resolve "${{ vars.DOMAIN }}:443:${{ vars.SSH_HOST }}" "https://${{ vars.DOMAIN }}/api/v1/properties?limit=1") echo "Properties: $S" [ "$S" = "200" ] || (echo "❌ Properties falhou ($S)" && exit 1) - name: Version tag matches run: | R=$(curl -s --max-time 10 --resolve "${{ vars.DOMAIN }}:443:${{ vars.SSH_HOST }}" "https://${{ vars.DOMAIN }}/api/version") echo "Version: $R" echo "$R" | grep -q "${{ needs.build.outputs.image_tag }}" \ || (echo "❌ Tag não confere — esperado ${{ needs.build.outputs.image_tag }}" && exit 1) - name: Summary run: | echo "✅ Todos os checks passaram!" echo " → https://${{ vars.DOMAIN }} está no ar com tag ${{ needs.build.outputs.image_tag }}"