stages: - test - build - deploy variables: DOCKERFILE_PATH: Dockerfile.production DEPLOY_ROOT: /opt/rentaldrivego # Required: disable TLS so the docker client can reach the dind daemon DOCKER_TLS_CERTDIR: "" DOCKER_HOST: tcp://docker:2375 # ==================================== # TEST STAGE # Runs on every push and merge request # ==================================== unit_tests: stage: test image: node:20-bookworm before_script: - npm ci - npm run db:generate script: - npm run type-check - npm run test:api rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH integration_tests: stage: test image: node:20-bookworm services: - name: postgres:16-alpine alias: postgres variables: POSTGRES_DB: rentaldrivego_test POSTGRES_USER: postgres POSTGRES_PASSWORD: password - name: redis:7-alpine alias: redis variables: # Used by db:deploy (Prisma migrations) and any code reading DATABASE_URL directly DATABASE_URL: "postgresql://postgres:password@postgres:5432/rentaldrivego_test" REDIS_URL: "redis://redis:6379" NODE_ENV: test before_script: - npm ci - npm run db:generate # Overwrite the local .env.test so vitest integration config gets CI service hostnames # (the file uses postgres/redis aliases, not localhost) - | cat > apps/api/.env.test << 'EOF' DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego_test REDIS_URL=redis://redis:6379 JWT_SECRET=test-secret JWT_EXPIRY=8h NODE_ENV=test FILE_STORAGE_ROOT=/tmp/rentaldrivego-test-storage EOF - npm run db:deploy script: - npm run test:api:integration rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # ==================================== # BUILD STAGE # ==================================== build_image: stage: build image: docker:24 services: - name: docker:24-dind alias: docker command: ["--tls=false"] variables: HEALTHCHECK_TCP_PORT: "2375" before_script: - | attempts=0 until docker info >/dev/null 2>&1; do attempts=$((attempts + 1)) if [ "$attempts" -ge 60 ]; then echo "Docker daemon did not become ready after 60 seconds." >&2 echo "On self-hosted runners, docker:dind requires the runner Docker executor to run with privileged = true." >&2 exit 1 fi echo "Waiting for Docker daemon..." sleep 1 done # Prefer GitLab's built-in registry variables, but allow explicit fallbacks # so the pipeline can publish to any OCI-compatible registry. - export REGISTRY_HOST="${CI_REGISTRY:-${REGISTRY_HOST:-}}" - export REGISTRY_USER="${CI_REGISTRY_USER:-${REGISTRY_USER:-}}" - export REGISTRY_PASSWORD="${CI_REGISTRY_PASSWORD:-${REGISTRY_PASSWORD:-}}" - export IMAGE_REPOSITORY="${CI_REGISTRY_IMAGE:-${REGISTRY_IMAGE:-}}" - test -n "$REGISTRY_HOST" || { echo "Registry host is not set. Configure CI_REGISTRY or REGISTRY_HOST."; exit 1; } - test -n "$REGISTRY_USER" || { echo "Registry user is not set. Configure CI_REGISTRY_USER or REGISTRY_USER."; exit 1; } - test -n "$REGISTRY_PASSWORD" || { echo "Registry password is not set. Configure CI_REGISTRY_PASSWORD or REGISTRY_PASSWORD."; exit 1; } - test -n "$IMAGE_REPOSITORY" || { echo "Image repository is not set. Configure CI_REGISTRY_IMAGE or REGISTRY_IMAGE."; exit 1; } - export DOCKER_IMAGE="$IMAGE_REPOSITORY:$CI_COMMIT_SHORT_SHA" - export DOCKER_IMAGE_LATEST="$IMAGE_REPOSITORY:latest" - echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin script: - echo "--- Building Docker Image ---" # NEXT_PUBLIC_* vars are inlined into Next.js bundles at build time — must be passed as ARGs - | docker build \ --build-arg NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ --build-arg NEXT_PUBLIC_MARKETPLACE_URL=$NEXT_PUBLIC_MARKETPLACE_URL \ --build-arg NEXT_PUBLIC_DASHBOARD_URL=$NEXT_PUBLIC_DASHBOARD_URL \ --build-arg NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \ -t $DOCKER_IMAGE \ -t $DOCKER_IMAGE_LATEST \ -f $DOCKERFILE_PATH . - echo "--- Pushing to Registry ---" - docker push $DOCKER_IMAGE - docker push $DOCKER_IMAGE_LATEST rules: # On the default branch, always attempt the image build so missing registry # configuration fails loudly in before_script instead of skipping the job. - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' - when: never # ==================================== # DEPLOY STAGE # ==================================== deploy_to_vps: stage: deploy image: alpine:latest needs: - build_image before_script: - export REGISTRY_HOST="${CI_REGISTRY:-${REGISTRY_HOST:-}}" - export REGISTRY_USER="${CI_REGISTRY_USER:-${REGISTRY_USER:-}}" - export REGISTRY_PASSWORD="${CI_REGISTRY_PASSWORD:-${REGISTRY_PASSWORD:-}}" - export IMAGE_REPOSITORY="${CI_REGISTRY_IMAGE:-${REGISTRY_IMAGE:-}}" - test -n "$REGISTRY_HOST" || { echo "Registry host is not set. Configure CI_REGISTRY or REGISTRY_HOST."; exit 1; } - test -n "$REGISTRY_USER" || { echo "Registry user is not set. Configure CI_REGISTRY_USER or REGISTRY_USER."; exit 1; } - test -n "$REGISTRY_PASSWORD" || { echo "Registry password is not set. Configure CI_REGISTRY_PASSWORD or REGISTRY_PASSWORD."; exit 1; } - test -n "$IMAGE_REPOSITORY" || { echo "Image repository is not set. Configure CI_REGISTRY_IMAGE or REGISTRY_IMAGE."; exit 1; } - export DOCKER_IMAGE="$IMAGE_REPOSITORY:$CI_COMMIT_SHORT_SHA" - apk add --no-cache openssh-client # Load the SSH private key stored in the CI variable SSH_PRIVATE_KEY - eval $(ssh-agent -s) - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - - mkdir -p ~/.ssh && chmod 700 ~/.ssh # Scan and trust the VPS host key (avoids manual known_hosts management) - ssh-keyscan -H $VPS_IP >> ~/.ssh/known_hosts - chmod 644 ~/.ssh/known_hosts script: - echo "--- Deploying $DOCKER_IMAGE to VPS ---" - | REGISTRY_PASSWORD_B64="$(printf '%s' "$REGISTRY_PASSWORD" | base64 | tr -d '\n')" echo "-- Syncing deployment assets --" ssh $VPS_USER@$VPS_IP " set -e mkdir -p '$DEPLOY_ROOT/scripts' '$DEPLOY_ROOT/docker/pgmanage' '$DEPLOY_ROOT/docker/registry/auth' '$DEPLOY_ROOT/dynamic' " scp docker-compose.production.yml docker-compose.portainer.production.yml docker-compose.registry.production.yml docker-compose.registry.local.yml traefik.yaml $VPS_USER@$VPS_IP:$DEPLOY_ROOT/ scp scripts/docker-prod-common.sh scripts/docker-prod-deploy.sh scripts/docker-prod-up-registry.sh scripts/docker-registry-local-up.sh $VPS_USER@$VPS_IP:$DEPLOY_ROOT/scripts/ scp docker/pgmanage/override.py $VPS_USER@$VPS_IP:$DEPLOY_ROOT/docker/pgmanage/ echo "-- Running deploy script on VPS --" ssh $VPS_USER@$VPS_IP " set -e cd '$DEPLOY_ROOT' APP_IMAGE='$IMAGE_REPOSITORY' \ APP_VERSION='$CI_COMMIT_SHORT_SHA' \ REGISTRY_HOST='$REGISTRY_HOST' \ REGISTRY_USER='$REGISTRY_USER' \ REGISTRY_PASSWORD=\$(printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d) \ bash scripts/docker-prod-deploy.sh " rules: # Deploy only when both registry access and VPS credentials are available. - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && ((($CI_REGISTRY && $CI_REGISTRY_USER && $CI_REGISTRY_PASSWORD && $CI_REGISTRY_IMAGE) || ($REGISTRY_HOST && $REGISTRY_USER && $REGISTRY_PASSWORD && $REGISTRY_IMAGE)) && $SSH_PRIVATE_KEY && $VPS_IP && $VPS_USER)' - when: never