diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da31de7b2d..c2f3006931 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -203,10 +203,8 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - uses: hashicorp/setup-terraform@5e8dbf3c6d9deaf4193ca7a8fb23f2ac83bb6c85 # v4 - run: cd deploy/cloudflare && terraform init -backend=false && terraform validate - - run: cd deploy/dokploy && terraform init -backend=false && terraform validate - - run: cd deploy/dokploy-compose && terraform init -backend=false && terraform validate - - run: cd deploy/dokploy-app && terraform init -backend=false && terraform validate - - run: cd deploy/dokploy-db && terraform init -backend=false && terraform validate + - run: cd deploy/server && terraform init -backend=false && terraform validate + - run: cd deploy/db && terraform init -backend=false && terraform validate quality-gate: name: Lint & Static Analysis @@ -1072,7 +1070,7 @@ jobs: && echo "ios=true" >> "$GITHUB_OUTPUT" || echo "ios=false" >> "$GITHUB_OUTPUT" # Infra compose changes → infra-compose deploy - echo "$DIFF" | grep -qE '^deploy/(dokploy/infra-compose\.yml|dokploy-compose/)' \ + echo "$DIFF" | grep -qE '^deploy/(docker-compose\.deploy\.yml|otel-collector-config\.yaml)' \ && echo "infra_compose=true" >> "$GITHUB_OUTPUT" || echo "infra_compose=false" >> "$GITHUB_OUTPUT" # Cloudflare changes → cloudflare deploy @@ -1215,12 +1213,19 @@ jobs: if: always() && needs.deploy-ready.outputs.latest == 'true' && (needs.deploy-web.result == 'success' || needs.deploy-worker.result == 'success') runs-on: ubuntu-latest steps: + - name: Setup SSH + env: + SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + SERVER_HOST: ${{ secrets.SERVER_HOST }} + run: | + mkdir -p ~/.ssh + echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H "$SERVER_HOST" >> ~/.ssh/known_hosts 2>/dev/null - name: Prune unused images env: - DOKPLOY_HOST: ${{ secrets.DOKPLOY_HOST }} - DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }} + SERVER_HOST: ${{ secrets.SERVER_HOST }} run: | - curl -fsSL -X POST "${DOKPLOY_HOST}/api/trpc/settings.cleanUnusedImages" \ - -H "x-api-key: ${DOKPLOY_API_KEY}" \ - -H "Content-Type: application/json" \ - -d '{"json":{}}' || echo "Image cleanup failed (non-fatal)" + ssh "root@${SERVER_HOST}" \ + 'docker image prune -af --filter "until=72h"' \ + || echo "Image cleanup failed (non-fatal)" diff --git a/.github/workflows/deploy-infra-compose.yml b/.github/workflows/deploy-infra-compose.yml index b738f8f06f..b5e33d0a9b 100644 --- a/.github/workflows/deploy-infra-compose.yml +++ b/.github/workflows/deploy-infra-compose.yml @@ -25,7 +25,7 @@ jobs: - uses: hashicorp/setup-terraform@5e8dbf3c6d9deaf4193ca7a8fb23f2ac83bb6c85 # v4 with: terraform_wrapper: false - - name: Setup SSH tunnel + - name: Setup SSH env: SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_KEY }} SERVER_HOST: ${{ secrets.SERVER_HOST }} @@ -34,45 +34,30 @@ jobs: echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519 ssh-keyscan -H "$SERVER_HOST" >> ~/.ssh/known_hosts 2>/dev/null - ssh -f -N -L 5432:127.0.0.1:5432 "root@${SERVER_HOST}" - - name: Render compose template + - name: Copy compose and config to server env: - TF_VAR_postgres_password: ${{ secrets.POSTGRES_PASSWORD }} - TF_VAR_axiom_api_token: ${{ secrets.AXIOM_API_TOKEN }} - TF_VAR_sentry_otlp_logs_endpoint: ${{ secrets.SENTRY_OTLP_LOGS_ENDPOINT }} - TF_VAR_r2_endpoint: ${{ secrets.R2_ENDPOINT }} - TF_VAR_r2_access_key_id: ${{ secrets.R2_ACCESS_KEY_ID }} - TF_VAR_r2_secret_access_key: ${{ secrets.R2_SECRET_ACCESS_KEY }} - TF_VAR_expo_app_id: ${{ secrets.EXPO_APP_ID }} - TF_VAR_expo_access_token: ${{ secrets.EXPO_ACCESS_TOKEN }} - TF_VAR_ota_jwt_secret: ${{ secrets.OTA_JWT_SECRET }} - TF_VAR_ota_public_key_b64: ${{ secrets.OTA_PUBLIC_KEY_B64 }} - TF_VAR_ota_private_key_b64: ${{ secrets.OTA_PRIVATE_KEY_B64 }} + SERVER_HOST: ${{ secrets.SERVER_HOST }} run: | - cd deploy/dokploy-compose - terraform init - terraform apply -auto-approve - - name: Deploy compose to Dokploy + scp deploy/docker-compose.deploy.yml "root@${SERVER_HOST}:/opt/dofek/" + scp deploy/otel-collector-config.yaml "root@${SERVER_HOST}:/opt/dofek/" + - name: Deploy infra services env: - DOKPLOY_HOST: ${{ secrets.DOKPLOY_HOST }} - DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }} + SERVER_HOST: ${{ secrets.SERVER_HOST }} run: | - set -euo pipefail - COMPOSE_CONTENT=$(cd deploy/dokploy-compose && terraform output -raw compose_rendered) - COMPOSE_JSON=$(printf '%s' "$COMPOSE_CONTENT" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))") - COMPOSE_ID="RMk3hATYEIKherZ2QEud_" + ssh "root@${SERVER_HOST}" bash -s <<'DEPLOY' + set -euo pipefail + cd /opt/dofek - echo "Updating infra compose in Dokploy..." - curl -fsSL -X POST "${DOKPLOY_HOST}/api/trpc/compose.update" \ - -H "x-api-key: ${DOKPLOY_API_KEY}" \ - -H "Content-Type: application/json" \ - -d "{\"json\":{\"composeId\":\"${COMPOSE_ID}\",\"sourceType\":\"raw\",\"composeFile\":${COMPOSE_JSON}}}" + # Pull updated infra images + docker compose --env-file .env.prod --env-file .env.deploy \ + -f docker-compose.deploy.yml pull \ + db redis collector ota db-backup traefik portainer netdata - echo "Deploying infra compose stack..." - curl -fsSL -X POST "${DOKPLOY_HOST}/api/trpc/compose.deploy" \ - -H "x-api-key: ${DOKPLOY_API_KEY}" \ - -H "Content-Type: application/json" \ - -d "{\"json\":{\"composeId\":\"${COMPOSE_ID}\"}}" + # Recreate only changed infra services (web/worker use rollout separately) + docker compose --env-file .env.prod --env-file .env.deploy \ + -f docker-compose.deploy.yml up -d \ + db redis collector ota db-backup traefik portainer netdata + DEPLOY - name: Wait for DB and upgrade extension env: TF_VAR_postgres_password: ${{ secrets.POSTGRES_PASSWORD }} @@ -81,7 +66,7 @@ jobs: set -euo pipefail echo "Waiting for DB to accept connections..." for attempt in $(seq 1 30); do - if ssh "root@${SERVER_HOST}" "docker exec \$(docker ps -q --filter name=db-1) pg_isready -U health -d health" 2>/dev/null; then + if ssh "root@${SERVER_HOST}" "docker exec dofek-db pg_isready -U health -d health" 2>/dev/null; then echo "DB is ready" break fi @@ -93,6 +78,9 @@ jobs: sleep 2 done - cd deploy/dokploy-db + # Open SSH tunnel for Terraform DB access + ssh -f -N -L 5432:127.0.0.1:5432 "root@${SERVER_HOST}" + + cd deploy/db terraform init terraform apply -auto-approve diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 7afde4cb24..ef18c05cf9 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -22,16 +22,36 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: ref: ${{ inputs.commit_sha != '' && inputs.commit_sha || github.sha }} - - uses: hashicorp/setup-terraform@5e8dbf3c6d9deaf4193ca7a8fb23f2ac83bb6c85 # v4 - with: - terraform_wrapper: false - - name: Deploy web to Dokploy + - name: Setup SSH + env: + SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + SERVER_HOST: ${{ secrets.SERVER_HOST }} + run: | + mkdir -p ~/.ssh + echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H "$SERVER_HOST" >> ~/.ssh/known_hosts 2>/dev/null + - name: Deploy web via docker rollout env: - TF_VAR_dokploy_host: ${{ secrets.DOKPLOY_HOST }} - TF_VAR_dokploy_api_key: ${{ secrets.DOKPLOY_API_KEY }} - TF_VAR_app_id: ${{ secrets.DOKPLOY_WEB_APP_ID }} + SERVER_HOST: ${{ secrets.SERVER_HOST }} + GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }} run: | SHA_TAG="sha-$(git rev-parse --short HEAD)" - cd deploy/dokploy-app - terraform init - terraform apply -auto-approve -var="image_tag=${SHA_TAG}" + ssh "root@${SERVER_HOST}" bash -s -- "$GHCR_TOKEN" "$SHA_TAG" <<'DEPLOY' + set -euo pipefail + GHCR_TOKEN="$1" + SHA_TAG="$2" + cd /opt/dofek + + # Authenticate with GHCR + echo "$GHCR_TOKEN" | docker login ghcr.io -u asherlc --password-stdin + + # Pull the new image + docker pull "ghcr.io/asherlc/dofek:${SHA_TAG}" + + # Persist the image tag for future compose operations + echo "IMAGE_TAG=${SHA_TAG}" > .env.deploy + + # Zero-downtime rollout (docker rollout is a CLI plugin, not a compose subcommand) + docker rollout -f docker-compose.deploy.yml web + DEPLOY diff --git a/.github/workflows/deploy-worker.yml b/.github/workflows/deploy-worker.yml index 6f66a4156b..297bf1c367 100644 --- a/.github/workflows/deploy-worker.yml +++ b/.github/workflows/deploy-worker.yml @@ -22,16 +22,36 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: ref: ${{ inputs.commit_sha != '' && inputs.commit_sha || github.sha }} - - uses: hashicorp/setup-terraform@5e8dbf3c6d9deaf4193ca7a8fb23f2ac83bb6c85 # v4 - with: - terraform_wrapper: false - - name: Deploy worker to Dokploy + - name: Setup SSH + env: + SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + SERVER_HOST: ${{ secrets.SERVER_HOST }} + run: | + mkdir -p ~/.ssh + echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H "$SERVER_HOST" >> ~/.ssh/known_hosts 2>/dev/null + - name: Deploy worker via docker rollout env: - TF_VAR_dokploy_host: ${{ secrets.DOKPLOY_HOST }} - TF_VAR_dokploy_api_key: ${{ secrets.DOKPLOY_API_KEY }} - TF_VAR_app_id: ${{ secrets.DOKPLOY_WORKER_APP_ID }} + SERVER_HOST: ${{ secrets.SERVER_HOST }} + GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }} run: | SHA_TAG="sha-$(git rev-parse --short HEAD)" - cd deploy/dokploy-app - terraform init - terraform apply -auto-approve -var="image_tag=${SHA_TAG}" + ssh "root@${SERVER_HOST}" bash -s -- "$GHCR_TOKEN" "$SHA_TAG" <<'DEPLOY' + set -euo pipefail + GHCR_TOKEN="$1" + SHA_TAG="$2" + cd /opt/dofek + + # Authenticate with GHCR + echo "$GHCR_TOKEN" | docker login ghcr.io -u asherlc --password-stdin + + # Pull the new image + docker pull "ghcr.io/asherlc/dofek:${SHA_TAG}" + + # Persist the image tag for future compose operations + echo "IMAGE_TAG=${SHA_TAG}" > .env.deploy + + # Zero-downtime rollout (docker rollout is a CLI plugin, not a compose subcommand) + docker rollout -f docker-compose.deploy.yml worker + DEPLOY diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9a2435a68c..5e51a0ba72 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -74,12 +74,19 @@ jobs: if: always() && (needs.web.result == 'success' || needs.worker.result == 'success') runs-on: ubuntu-latest steps: + - name: Setup SSH + env: + SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + SERVER_HOST: ${{ secrets.SERVER_HOST }} + run: | + mkdir -p ~/.ssh + echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H "$SERVER_HOST" >> ~/.ssh/known_hosts 2>/dev/null - name: Prune unused images env: - DOKPLOY_HOST: ${{ secrets.DOKPLOY_HOST }} - DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }} + SERVER_HOST: ${{ secrets.SERVER_HOST }} run: | - curl -fsSL -X POST "${DOKPLOY_HOST}/api/trpc/settings.cleanUnusedImages" \ - -H "x-api-key: ${DOKPLOY_API_KEY}" \ - -H "Content-Type: application/json" \ - -d '{"json":{}}' || echo "Image cleanup failed (non-fatal)" + ssh "root@${SERVER_HOST}" \ + 'docker image prune -af --filter "until=72h"' \ + || echo "Image cleanup failed (non-fatal)" diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml deleted file mode 100644 index a845a30db3..0000000000 --- a/.github/workflows/preview-deploy.yml +++ /dev/null @@ -1,239 +0,0 @@ -name: Preview Deploy - -on: - pull_request: - types: [opened, synchronize, reopened, closed] - -concurrency: - group: preview-${{ github.event.pull_request.number }} - cancel-in-progress: ${{ github.event.action != 'closed' }} - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -jobs: - build-preview: - name: Build Preview Image - if: >- - github.event.action != 'closed' && - github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-24.04-arm - permissions: - contents: read - packages: write - outputs: - image-tag: ${{ steps.meta.outputs.version }} - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - with: - config-inline: | - [registry."docker.io"] - mirrors = ["mirror.gcr.io"] - - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 - id: meta - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: type=raw,value=pr-${{ github.event.pull_request.number }} - - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 - with: - context: . - target: server - platforms: linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - build-args: COMMIT_HASH=${{ github.sha }} - secrets: | - SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} - cache-from: type=gha,scope=preview-${{ github.event.pull_request.number }} - cache-to: type=gha,mode=max,scope=preview-${{ github.event.pull_request.number }} - - deploy-preview: - name: Deploy Preview - needs: [build-preview] - if: github.event.action != 'closed' - runs-on: ubuntu-latest - permissions: - pull-requests: write - env: - DOKPLOY_HOST: ${{ secrets.DOKPLOY_HOST }} - DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }} - DOKPLOY_PREVIEW_ENV_ID: ${{ secrets.DOKPLOY_PREVIEW_ENV_ID }} - DOKPLOY_REGISTRY_ID: ${{ secrets.DOKPLOY_REGISTRY_ID }} - PREVIEW_DATABASE_URL: ${{ secrets.PREVIEW_DATABASE_URL }} - PR_NUMBER: ${{ github.event.pull_request.number }} - PREVIEW_DOMAIN: pr-${{ github.event.pull_request.number }}.preview.dofek.fit - DOCKER_IMAGE: ghcr.io/asherlc/dofek:pr-${{ github.event.pull_request.number }} - steps: - - name: Check required secrets - run: | - if [ -z "$DOKPLOY_HOST" ] || [ -z "$DOKPLOY_API_KEY" ] || [ -z "$DOKPLOY_PREVIEW_ENV_ID" ]; then - echo "::error::Required secrets not configured: DOKPLOY_HOST, DOKPLOY_API_KEY, DOKPLOY_PREVIEW_ENV_ID" - exit 1 - fi - - - name: Create or update preview app - run: | - set -euo pipefail - - dokploy() { - local endpoint="$1" - shift - curl -fsSL "${DOKPLOY_HOST}/api/trpc/${endpoint}" \ - -H "x-api-key: ${DOKPLOY_API_KEY}" \ - -H "Content-Type: application/json" \ - "$@" - } - - APP_NAME="pr-${PR_NUMBER}-preview" - - # Check if app already exists by listing all apps in the preview environment - EXISTING=$(dokploy "application.byEnvironment?input=$(printf '%s' "{\"json\":\"${DOKPLOY_PREVIEW_ENV_ID}\"}" | jq -sRr @uri)" 2>/dev/null || echo "[]") - APP_ID=$(echo "$EXISTING" | jq -r --arg name "$APP_NAME" '.result.data.json[] | select(.appName == $name) | .applicationId' 2>/dev/null || true) - - if [ -z "$APP_ID" ]; then - echo "Creating new preview app: ${APP_NAME}" - CREATE_RESULT=$(dokploy "application.create" \ - -d "{\"json\":{\"name\":\"PR #${PR_NUMBER} Preview\",\"appName\":\"${APP_NAME}\",\"environmentId\":\"${DOKPLOY_PREVIEW_ENV_ID}\"}}") - APP_ID=$(echo "$CREATE_RESULT" | jq -r '.result.data.json.applicationId') - echo "Created app: $APP_ID" - - # Configure as Docker source with image - dokploy "application.update" \ - -d "{\"json\":{\"applicationId\":\"${APP_ID}\",\"sourceType\":\"docker\",\"dockerImage\":\"${DOCKER_IMAGE}\"}}" > /dev/null - - # Set command - dokploy "application.update" \ - -d "{\"json\":{\"applicationId\":\"${APP_ID}\",\"command\":\"./entrypoint.sh web\"}}" > /dev/null - - # Set env vars - PREVIEW_ENV=$(printf 'PORT=3000\nDATABASE_URL=%s\nREDIS_URL=redis://dofek-redis:6379\nNODE_ENV=production\nPUBLIC_URL=https://%s\nSKIP_VIEW_SYNC=true' "$PREVIEW_DATABASE_URL" "$PREVIEW_DOMAIN") - dokploy "application.update" \ - -d "$(jq -n --arg id "$APP_ID" --arg env "$PREVIEW_ENV" '{json: {applicationId: $id, env: $env}}')" > /dev/null - - # Create domain - dokploy "domain.create" \ - -d "{\"json\":{\"applicationId\":\"${APP_ID}\",\"host\":\"${PREVIEW_DOMAIN}\",\"port\":3000,\"https\":true,\"certificateType\":\"letsencrypt\"}}" > /dev/null - echo "Domain configured: https://${PREVIEW_DOMAIN}" - else - echo "Updating existing preview app: $APP_ID" - # Update the Docker image tag - dokploy "application.update" \ - -d "{\"json\":{\"applicationId\":\"${APP_ID}\",\"sourceType\":\"docker\",\"dockerImage\":\"${DOCKER_IMAGE}\"}}" > /dev/null - fi - - # Deploy - echo "Deploying..." - dokploy "application.deploy" \ - -d "{\"json\":{\"applicationId\":\"${APP_ID}\",\"title\":\"PR #${PR_NUMBER} @ ${GITHUB_SHA:0:7}\"}}" > /dev/null - - echo "APP_ID=${APP_ID}" >> "$GITHUB_ENV" - - - name: Comment preview URL - if: env.SKIP_DEPLOY != 'true' - uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2 - with: - header: preview-deploy - message: | - **Preview deployed!** :rocket: - https://${{ env.PREVIEW_DOMAIN }} - _Built from ${{ github.sha }}_ - - teardown-preview: - name: Teardown Preview - if: github.event.action == 'closed' - runs-on: ubuntu-latest - permissions: - pull-requests: write - env: - DOKPLOY_HOST: ${{ secrets.DOKPLOY_HOST }} - DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }} - DOKPLOY_PREVIEW_ENV_ID: ${{ secrets.DOKPLOY_PREVIEW_ENV_ID }} - PR_NUMBER: ${{ github.event.pull_request.number }} - steps: - - name: Delete preview app - run: | - set -euo pipefail - - if [ -z "$DOKPLOY_HOST" ] || [ -z "$DOKPLOY_API_KEY" ] || [ -z "$DOKPLOY_PREVIEW_ENV_ID" ]; then - echo "::error::Required secrets not configured: DOKPLOY_HOST, DOKPLOY_API_KEY, DOKPLOY_PREVIEW_ENV_ID" - exit 1 - fi - - dokploy() { - local endpoint="$1" - shift - curl -fsSL "${DOKPLOY_HOST}/api/trpc/${endpoint}" \ - -H "x-api-key: ${DOKPLOY_API_KEY}" \ - -H "Content-Type: application/json" \ - "$@" - } - - APP_NAME="pr-${PR_NUMBER}-preview" - - # Find the app - EXISTING=$(dokploy "application.byEnvironment?input=$(printf '%s' "{\"json\":\"${DOKPLOY_PREVIEW_ENV_ID}\"}" | jq -sRr @uri)" 2>/dev/null || echo "[]") - APP_ID=$(echo "$EXISTING" | jq -r --arg name "$APP_NAME" '.result.data.json[] | select(.appName == $name) | .applicationId' 2>/dev/null || true) - - if [ -n "$APP_ID" ]; then - echo "Deleting preview app: ${APP_NAME} (${APP_ID})" - dokploy "application.delete" \ - -d "{\"json\":{\"applicationId\":\"${APP_ID}\"}}" > /dev/null - echo "Preview app deleted" - else - echo "No preview app found for PR #${PR_NUMBER}, nothing to clean up" - fi - - - name: Update PR comment - if: always() - uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2 - with: - header: preview-deploy - message: | - **Preview torn down** :wastebasket: - _PR closed/merged_ - - # Clean up the specific preview image tag from GHCR - cleanup-image: - name: Cleanup Preview Image - if: github.event.action == 'closed' - runs-on: ubuntu-latest - permissions: - packages: write - env: - PR_NUMBER: ${{ github.event.pull_request.number }} - steps: - - name: Delete preview image tag - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - - TAG="pr-${PR_NUMBER}" - PACKAGE="dofek" - OWNER="asherlc" - - # Find the specific version ID for this PR tag - VERSION_ID=$(curl -fsSL \ - -H "Authorization: Bearer ${GH_TOKEN}" \ - -H "Accept: application/vnd.github.v3+json" \ - "https://api.github.com/users/${OWNER}/packages/container/${PACKAGE}/versions" \ - | jq -r --arg tag "$TAG" '.[] | select(.metadata.container.tags | index($tag)) | .id' 2>/dev/null || true) - - if [ -n "$VERSION_ID" ]; then - echo "Deleting GHCR image version ${VERSION_ID} (tag: ${TAG})" - curl -fsSL -X DELETE \ - -H "Authorization: Bearer ${GH_TOKEN}" \ - -H "Accept: application/vnd.github.v3+json" \ - "https://api.github.com/users/${OWNER}/packages/container/${PACKAGE}/versions/${VERSION_ID}" \ - || echo "::warning::Failed to delete image version, may need manual cleanup" - else - echo "No GHCR image found with tag ${TAG}, nothing to clean up" - fi diff --git a/.github/workflows/secret-sync.yml b/.github/workflows/secret-sync.yml index 51d446a6e7..0fd8d43ff2 100644 --- a/.github/workflows/secret-sync.yml +++ b/.github/workflows/secret-sync.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: inputs: dry_run: - description: "Run sync in dry-run mode (no Dokploy updates or deploys)" + description: "Dry-run mode (export secrets but don't restart services)" type: boolean default: false schedule: @@ -18,32 +18,50 @@ concurrency: jobs: sync: - name: Sync Infisical to Dokploy + name: Sync Infisical to Server runs-on: ubuntu-latest permissions: contents: read steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - uses: ./.github/actions/setup-node-pnpm - - name: Sync managed secrets + - name: Setup SSH env: + SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + SERVER_HOST: ${{ secrets.SERVER_HOST }} + run: | + mkdir -p ~/.ssh + echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H "$SERVER_HOST" >> ~/.ssh/known_hosts 2>/dev/null + - name: Export secrets from Infisical + env: + SERVER_HOST: ${{ secrets.SERVER_HOST }} INFISICAL_TOKEN: ${{ secrets.INFISICAL_TOKEN }} - DOKPLOY_HOST: ${{ secrets.DOKPLOY_HOST }} - DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }} - DOKPLOY_WEB_APP_ID: ${{ secrets.DOKPLOY_WEB_APP_ID }} - DOKPLOY_WORKER_APP_ID: ${{ secrets.DOKPLOY_WORKER_APP_ID }} - SYNC_TRIGGER_EVENT_NAME: ${{ github.event_name }} - SYNC_DRY_RUN_INPUT: ${{ github.event.inputs.dry_run || 'false' }} + DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} run: | - set -euo pipefail - dry_run_flag="" - if [ "$SYNC_TRIGGER_EVENT_NAME" = "workflow_dispatch" ] && [ "$SYNC_DRY_RUN_INPUT" = "true" ]; then - dry_run_flag="--dry-run" - fi - - pnpm tsx scripts/sync-dokploy-env-from-infisical.ts \ - --environment=prod \ - --apps=web,worker \ - --managed-keys-file=deploy/dokploy/managed-secret-keys.json \ - --fail-on-missing=true \ - $dry_run_flag + # Pass the token via env var (not positional arg) to avoid process listing exposure + ssh -o SendEnv=INFISICAL_TOKEN "root@${SERVER_HOST}" bash -s -- "$DRY_RUN" <<'SYNC' + set -euo pipefail + DRY_RUN="$1" + + # Export secrets from Infisical to .env file + infisical export --env=prod --format=dotenv \ + > /opt/dofek/.env.prod.new + + chmod 600 /opt/dofek/.env.prod.new + + if [ "$DRY_RUN" = "true" ]; then + echo "Dry run — comparing secrets:" + diff /opt/dofek/.env.prod /opt/dofek/.env.prod.new || true + rm /opt/dofek/.env.prod.new + exit 0 + fi + + # Atomically replace the env file + mv /opt/dofek/.env.prod.new /opt/dofek/.env.prod + + # Restart services that use env_file to pick up new secrets + cd /opt/dofek + docker compose --env-file .env.prod --env-file .env.deploy \ + -f docker-compose.deploy.yml restart web worker ota collector + echo "Secrets synced and services restarted" + SYNC diff --git a/AGENTS.md b/AGENTS.md index 35417ed88a..a3f01669e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ Provider-agnostic fitness/health data pipeline. Syncs data from various provider - **Document as you go**: Keep README.md and docs/ updated with every significant change. When learning about external APIs, data formats, auth protocols, or provider quirks, write notes in `docs/` (e.g., `docs/peloton.md`, `docs/apple-health.md`). These notes help future development and debugging. - **Run migrations**: After generating a migration from schema changes, always run `pnpm migrate` yourself — don't tell the user to do it. - **Never modify repo settings**: Never change GitHub branch protection rules, required status checks, repo rulesets, or any other repository-level settings via the API or CLI. If branch protection is blocking a merge, ask the user how to proceed. -- **No manual server changes**: Never SSH into the server to edit config files directly. All server config changes must go through Dokploy (UI or API) or Terraform. See the README for details. SSH is allowed for **debugging** (reading logs, checking container status, inspecting state) but not for making changes — the fix must be a code/infrastructure change that handles the failure automatically. +- **No manual server changes**: Never SSH into the server to edit config files directly. All server config changes must go through the deploy compose file (committed to git) or Terraform. See the README for details. SSH is allowed for **debugging** (reading logs, checking container status, inspecting state) but not for making changes — the fix must be a code/infrastructure change that handles the failure automatically. - **Drizzle generate is interactive**: `pnpm generate` (`drizzle-kit generate`) prompts interactively when it detects potential table/column renames. Since CLI tools can't handle interactive prompts, write migration SQL files manually when `generate` would prompt. Name them sequentially (e.g., `drizzle/0012_description.sql`). Use `ALTER TABLE ... ADD COLUMN` for new columns, etc. Always run `pnpm migrate` after creating manual migrations. ## Code Review @@ -141,6 +141,6 @@ packages/ trainingpeaks-connect/ — RE'd TrainingPeaks internal API client cypress/ — E2E tests (Cypress) drizzle/ — SQL migrations -deploy/ — Terraform (Hetzner + Cloudflare) + Dokploy setup +deploy/ — Terraform (Hetzner + Cloudflare) + Docker Compose deploy Dockerfile — Multi-stage: server target (includes built web assets) ``` diff --git a/README.md b/README.md index ba1e2edbe6..b197d7a431 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ dofek/ ├── cypress/ # E2E tests (Cypress) ├── drizzle/ # SQL migrations (0000_baseline.sql + forward migrations) │ └── _views/ # Canonical materialized view definitions -├── deploy/ # Terraform + Docker Compose + Caddy +├── deploy/ # Terraform + Docker Compose (production stack) └── Dockerfile # Multi-stage: server image with built web assets ``` @@ -185,20 +185,19 @@ Deployed on a Hetzner Cloud CAX11 (ARM) server at `dofek.asherlc.com`. ### Infrastructure -The `deploy/` directory contains Terraform and Dokploy configuration: +The `deploy/` directory contains Terraform and Docker Compose configuration: ``` deploy/ -├── dokploy/ -│ ├── main.tf # Terraform — Hetzner server with Dokploy pre-installed -│ ├── infra-compose.yml # Docker Compose for infra services (DB, Redis, OTA, OTel, etc.) -│ ├── setup.sh # One-time Dokploy setup via tRPC API -│ └── otel-collector-config.yaml +├── docker-compose.deploy.yml # Full production stack (all services) +├── otel-collector-config.yaml # OTel Collector config +├── server/main.tf # Terraform — Hetzner server with Docker + docker-rollout ├── cloudflare/main.tf # Terraform — DNS, R2 buckets (Terraform Cloud: dofek-cloudflare) +├── db/main.tf # Terraform — TimescaleDB extension management └── .gitignore ``` -**Dokploy** manages application deployments (web, worker) and infra services. Traefik handles TLS termination and reverse proxy. The Dokploy dashboard is at `dokploy.asherlc.com`. +Docker Compose manages all services. Traefik handles TLS termination (Let's Encrypt via Cloudflare DNS challenge) and reverse proxy. Zero-downtime deploys use [docker-rollout](https://github.com/Wowu/docker-rollout). Management UIs: Portainer (`portainer.dofek.asherlc.com`) for Docker, Netdata (`netdata.dofek.asherlc.com`) for server health — both behind Authentik forward auth. ### Production architecture @@ -216,23 +215,20 @@ Internet → Traefik (auto-HTTPS :443, serves dofek.asherlc.com + dofek.fit + do ### Services -Dokploy manages two application services and an infra compose stack: - -**Applications** (deployed via GHCR image updates): - -| Service | Image | Purpose | -|---------|-------|---------| -| `dofek-web` | ghcr.io/asherlc/dofek | Express + tRPC API + static file serving (port 3000) | -| `dofek-worker` | ghcr.io/asherlc/dofek | BullMQ job worker (processes sync jobs, file imports) | - -**Infra compose stack** (`deploy/dokploy/infra-compose.yml`): +All services run in a single Docker Compose stack (`deploy/docker-compose.deploy.yml`): | Service | Image | Purpose | |---------|-------|---------| +| `traefik` | traefik:3.4 | Reverse proxy, auto-HTTPS via Cloudflare DNS challenge | +| `web` | ghcr.io/asherlc/dofek | Express + tRPC API + static file serving (port 3000) | +| `worker` | ghcr.io/asherlc/dofek | BullMQ job worker (processes sync jobs, file imports) | | `db` | timescale/timescaledb:2.26.2-pg18 | TimescaleDB (persistent volume) | | `redis` | redis:7-alpine | Job queue backend for BullMQ + OTA cache | | `ota` | ghcr.io/axelmarciano/expo-open-ota | Self-hosted Expo OTA server (ota.dofek.asherlc.com) | | `collector` | otel/opentelemetry-collector-contrib | OTel Collector — logs/traces → Axiom | +| `db-backup` | prodrigestivill/postgres-backup-local | Daily DB backups (7d/4w/6m retention) | +| `portainer` | portainer/portainer-ce | Docker management UI (portainer.dofek.asherlc.com) | +| `netdata` | netdata/netdata | Server health monitoring (netdata.dofek.asherlc.com) | ### Checking mobile OTA update status @@ -258,7 +254,7 @@ The API serves `/api/updates/*` directly from R2. The runtime version must match ``` git push → GHA builds ARM Docker images + exports Expo OTA bundle → signs manifest → uploads to R2 -→ Docker image pushed to GHCR (sha-tagged) → CI calls Dokploy API (update image + deploy) → Dokploy pulls new image + restarts containers +→ Docker image pushed to GHCR (sha-tagged) → CI SSHs to server → docker rollout (zero-downtime) ``` Deploy automation now runs through reusable GitHub workflows (`deploy-*.yml`) called directly from `ci.yml` (same workflow DAG, no cross-workflow dispatch). Each deploy receives the exact CI commit SHA, and deploy jobs run only when that SHA is still the current `main` head. @@ -267,15 +263,17 @@ Migrations run at two levels for reliability: a dedicated one-shot `migrate` con ### Deploying from scratch -1. Provision the server: `cd deploy/dokploy && terraform apply` -2. Run the one-time Dokploy setup: `cd deploy/dokploy && ./setup.sh` -3. Apply DNS/R2: `cd deploy/cloudflare && terraform apply` - -See `deploy/dokploy/terraform.tfvars.example` for required variables. +1. Provision the server: `cd deploy/server && terraform apply` +2. Apply DNS/R2: `cd deploy/cloudflare && terraform apply` +3. Export secrets: SSH to server, run `infisical export --env=prod --format=dotenv --token= > /opt/dofek/.env.prod` +4. Copy compose file: `scp deploy/docker-compose.deploy.yml root@:/opt/dofek/` +5. Copy OTel config: `scp deploy/otel-collector-config.yaml root@:/opt/dofek/` +6. Create initial deploy tag: `echo "IMAGE_TAG=latest" > /opt/dofek/.env.deploy` +7. Start the stack: `docker compose --env-file .env.prod --env-file .env.deploy -f docker-compose.deploy.yml up -d` ### Updating server config -**Never SSH into the server to edit config files directly.** Use the Dokploy dashboard or API for application and infra compose changes. Use Terraform for DNS and R2 bucket changes. +**Never SSH into the server to edit config files directly.** All changes go through the deploy compose file (committed to git) or Terraform. SSH is allowed for debugging only. ### SSH access @@ -292,7 +290,7 @@ ssh root@ **In-browser (easiest):** The Data Sources page has a "System Logs" panel that shows the most recent server log entries from the in-memory ring buffer (currently queried at `limit=100`). This is the fastest way to check OAuth errors, sync failures, and recent provider activity. -**Docker container logs (SSH):** Use `docker ps` to find container names (Dokploy uses its own naming convention): +**Docker container logs (SSH):** ```bash ssh root@ @@ -302,7 +300,7 @@ docker logs --tail 100 # container logs docker logs -f # follow logs in real-time ``` -Container management (restart, redeploy) should be done through the Dokploy dashboard at `dokploy.asherlc.com`. +Container management (restart, inspect, exec) can be done through Portainer at `portainer.dofek.asherlc.com` or via SSH. Server health monitoring is at `netdata.dofek.asherlc.com`. **Axiom (centralized):** Application logs, traces, and Docker container logs are shipped to [Axiom](https://axiom.co) via the OpenTelemetry Collector sidecar. In the current collector config, logs and traces both land in `dofek-logs`, and metrics land in `dofek-metrics`. This is the most complete log source because it survives container restarts and preserves structured metadata. @@ -391,23 +389,21 @@ The production containers get environment variables from two places: 1. **Committed `.env` (this repo)** — non-secret config: client IDs, redirect URIs, endpoints, DSNs. Baked into the Docker image. Loaded by the entrypoint on startup. -2. **Dokploy environment variables** — runtime app secrets are synced from Infisical to Dokploy app env by the `secret-sync.yml` workflow, then containers are redeployed. Infra compose services get secrets baked into `infra-compose.yml` during setup. +2. **`/opt/dofek/.env.prod` on the server** — runtime secrets exported from Infisical via `secret-sync.yml` workflow. Referenced by compose services via `env_file`. Structural config like `DATABASE_URL` is set in the compose file's `environment:` block. **Adding or updating secrets:** ```bash infisical secrets set --env prod KEY=value # Triggers Infisical webhook -> GitHub repository_dispatch -> secret-sync workflow -# Workflow updates Dokploy app env and redeploys only changed apps +# Workflow exports secrets to server and restarts affected services ``` No SSH to the server needed. No image rebuild needed. -**Managed sync scope:** `deploy/dokploy/managed-secret-keys.json` is the allowlist for keys owned by automated sync. Only those keys are updated; unmanaged Dokploy env vars are preserved. - -**Adding or updating non-secret config:** Edit `.env` in this repo, commit, push. CI builds a new image; Watchtower deploys it. +**Adding or updating non-secret config:** Edit `.env` in this repo, commit, push. CI builds a new image and deploys via docker-rollout. -**Important:** Infisical-injected vars override Docker/compose env vars. Never put `DATABASE_URL` in Infisical — it must come from the compose file. +**Important:** Compose `environment:` block values override `env_file` values. Never put `DATABASE_URL` in Infisical — it must come from the compose file. ### Troubleshooting @@ -415,12 +411,11 @@ No SSH to the server needed. No image rebuild needed. **If a provider is missing from the Data Sources page** it usually means `validate()` is failing, so the provider is being filtered out entirely rather than shown disabled. Check: 1. Are the vars in Infisical? → `infisical secrets get --env=prod` -2. Is the key in `deploy/dokploy/managed-secret-keys.json` for the target app (`web` or `worker`)? -3. Did `secret-sync.yml` run successfully? → `gh run list --workflow secret-sync.yml` -4. Force sync (dry-run first, then real run): +2. Did `secret-sync.yml` run successfully? → `gh run list --workflow secret-sync.yml` +3. Force sync (dry-run first, then real run): - `gh workflow run secret-sync.yml -f dry_run=true` - `gh workflow run secret-sync.yml -f dry_run=false` -5. If the app still shows stale config after sync, check deploy logs in Dokploy and refresh app caches. +4. If the app still shows stale config after sync, check container logs via Portainer or SSH and refresh app caches. ## Supplements @@ -562,7 +557,7 @@ Environment variables are split into two tiers: | Tier | Where | Examples | Needs rebuild? | |------|-------|----------|----------------| | **Non-secret config** | Committed `.env` in this repo | Client IDs, redirect URIs, endpoints, DSNs | Yes (baked into image) | -| **Secrets** | [Infisical](https://infisical.com/) (prod environment) → synced to Dokploy app env | Client secrets, API keys, tokens, private keys | No (sync + app redeploy) | +| **Secrets** | [Infisical](https://infisical.com/) (prod environment) → exported to `/opt/dofek/.env.prod` on server | Client secrets, API keys, tokens, private keys | No (sync + service restart) | ### Setup (new machine) @@ -610,7 +605,7 @@ infisical secrets delete KEY --env=prod --type shared ### Automatic production sync -Production secret propagation to Dokploy is automated by `.github/workflows/secret-sync.yml`. +Production secret propagation is automated by `.github/workflows/secret-sync.yml`. It SSHs to the server, runs `infisical export` to write `/opt/dofek/.env.prod`, then restarts affected services. - Primary trigger: `repository_dispatch` type `infisical-secrets-updated` (from Infisical webhook) - Recovery trigger: nightly schedule @@ -618,10 +613,8 @@ Production secret propagation to Dokploy is automated by `.github/workflows/secr Required GitHub secrets for this workflow: - `INFISICAL_TOKEN` -- `DOKPLOY_HOST` -- `DOKPLOY_API_KEY` -- `DOKPLOY_WEB_APP_ID` -- `DOKPLOY_WORKER_APP_ID` +- `DEPLOY_SSH_KEY` +- `SERVER_HOST` To force a manual sync: @@ -629,15 +622,14 @@ To force a manual sync: # Validate diff only gh workflow run secret-sync.yml -f dry_run=true -# Apply changes + redeploy changed apps +# Apply changes + restart services gh workflow run secret-sync.yml -f dry_run=false ``` ### Adding a new env var -- **Is it a secret?** (API key, token, password, private key, client secret) → Add to Infisical: `infisical secrets set --env=prod KEY=value` -- **Is it non-secret config?** (client ID, redirect URI, endpoint, DSN) → Add to the committed `.env` at the repo root -- **If it is an app secret that must auto-sync to Dokploy:** add it to `deploy/dokploy/managed-secret-keys.json` under `apps.web` and/or `apps.worker`. +- **Is it a secret?** (API key, token, password, private key, client secret) → Add to Infisical: `infisical secrets set --env=prod KEY=value`. The `secret-sync.yml` workflow will export it to the server. +- **Is it non-secret config?** (client ID, redirect URI, endpoint, DSN) → Add to the committed `.env` at the repo root. ### Production machine identity diff --git a/deploy/cloudflare/main.tf b/deploy/cloudflare/main.tf index ba033ea815..c07b3d1141 100644 --- a/deploy/cloudflare/main.tf +++ b/deploy/cloudflare/main.tf @@ -115,6 +115,24 @@ resource "cloudflare_dns_record" "ota_dofek_asherlc" { ttl = 1 } +resource "cloudflare_dns_record" "portainer_dofek_asherlc" { + zone_id = data.cloudflare_zone.asherlc_com.zone_id + type = "A" + name = "portainer.dofek.asherlc.com" + content = var.server_ip + proxied = false + ttl = 1 +} + +resource "cloudflare_dns_record" "netdata_dofek_asherlc" { + zone_id = data.cloudflare_zone.asherlc_com.zone_id + type = "A" + name = "netdata.dofek.asherlc.com" + content = var.server_ip + proxied = false + ttl = 1 +} + # --- R2 Storage --- resource "cloudflare_r2_bucket" "training_data" { @@ -157,9 +175,7 @@ resource "cloudflare_r2_custom_domain" "storybook_preview" { # tokens per bucket). Then add R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY to # Infisical (prod environment). # -# After applying, configure Dokploy backups: -# 1. Dokploy → Settings → S3 Destinations → add R2 endpoint + credentials -# 2. Database service → Backups → add schedule pointing to dofek-db-backups +# After applying, store R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY in Infisical. # --- Outputs --- @@ -189,7 +205,7 @@ output "r2_storybook_bucket_name" { } output "r2_db_backups_bucket_name" { - description = "R2 bucket name for database backups (configure in Dokploy)" + description = "R2 bucket name for database backups" value = cloudflare_r2_bucket.db_backups.name } diff --git a/deploy/dokploy-db/main.tf b/deploy/db/main.tf similarity index 100% rename from deploy/dokploy-db/main.tf rename to deploy/db/main.tf diff --git a/deploy/docker-compose.deploy.yml b/deploy/docker-compose.deploy.yml new file mode 100644 index 0000000000..739c25bd3d --- /dev/null +++ b/deploy/docker-compose.deploy.yml @@ -0,0 +1,269 @@ +# Production stack: all services, Traefik reverse proxy, management UIs. +# +# Secrets come from /opt/dofek/.env.prod (exported by Infisical CLI). +# Image tag comes from /opt/dofek/.env.deploy (written by CI). +# +# Usage: +# docker compose --env-file /opt/dofek/.env.prod --env-file /opt/dofek/.env.deploy \ +# -f docker-compose.deploy.yml up -d + +name: dofek + +services: + # ── Reverse Proxy ────────────────────────────────────────── + traefik: + image: traefik:3.4 + container_name: dofek-traefik + restart: unless-stopped + command: + - --api.dashboard=false + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --providers.docker.network=dofek_default + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --entrypoints.web.http.redirections.entrypoint.scheme=https + - --certificatesresolvers.le.acme.dnschallenge=true + - --certificatesresolvers.le.acme.dnschallenge.provider=cloudflare + - --certificatesresolvers.le.acme.email=asherlc@asherlc.com + - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json + environment: + CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN} + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - letsencrypt:/letsencrypt + labels: + # Authentik forward auth middleware (used by portainer + netdata) + - traefik.enable=true + - traefik.http.middlewares.authentik-auth.forwardAuth.address=https://authentik.asherlc.com/outpost.goauthentik.io/auth/traefik + - traefik.http.middlewares.authentik-auth.forwardAuth.trustForwardHeader=true + - traefik.http.middlewares.authentik-auth.forwardAuth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name,X-authentik-uid + + # ── Web Application ──────────────────────────────────────── + web: + image: ghcr.io/asherlc/dofek:${IMAGE_TAG:-latest} + # No container_name — docker-rollout needs to scale to 2 instances + restart: unless-stopped + command: ["web"] + env_file: /opt/dofek/.env.prod + environment: + NODE_ENV: production + PORT: "3000" + JOB_FILES_DIR: /app/job-files + PUBLIC_URL: https://dofek.asherlc.com + DATABASE_URL: postgres://health:${POSTGRES_PASSWORD}@db:5432/health + REDIS_URL: redis://redis:6379 + OTEL_SERVICE_NAME: dofek-web + OTEL_EXPORTER_OTLP_ENDPOINT: http://collector:4318 + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: http://collector:4318/v1/logs + volumes: + - job_files:/app/job-files + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:3000/healthz | grep -q ok"] + interval: 5s + timeout: 3s + retries: 5 + start_period: 15s + labels: + - traefik.enable=true + - traefik.http.routers.web.rule=Host(`dofek.asherlc.com`) || Host(`dofek.fit`) || Host(`www.dofek.fit`) || Host(`dofek.live`) || Host(`www.dofek.live`) + - traefik.http.routers.web.entrypoints=websecure + - traefik.http.routers.web.tls=true + - traefik.http.routers.web.tls.certresolver=le + - traefik.http.routers.web.tls.domains[0].main=dofek.asherlc.com + - traefik.http.routers.web.tls.domains[1].main=dofek.fit + - traefik.http.routers.web.tls.domains[1].sans=www.dofek.fit + - traefik.http.routers.web.tls.domains[2].main=dofek.live + - traefik.http.routers.web.tls.domains[2].sans=www.dofek.live + - traefik.http.services.web.loadbalancer.server.port=3000 + + # ── Worker ───────────────────────────────────────────────── + worker: + image: ghcr.io/asherlc/dofek:${IMAGE_TAG:-latest} + # No container_name — docker-rollout needs to scale to 2 instances + restart: unless-stopped + command: ["worker"] + env_file: /opt/dofek/.env.prod + environment: + NODE_ENV: production + DATABASE_URL: postgres://health:${POSTGRES_PASSWORD}@db:5432/health + REDIS_URL: redis://redis:6379 + OTEL_SERVICE_NAME: dofek-worker + OTEL_EXPORTER_OTLP_ENDPOINT: http://collector:4318 + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: http://collector:4318/v1/logs + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "pgrep -f worker || exit 1"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 15s + + # ── Database ─────────────────────────────────────────────── + db: + image: timescale/timescaledb:2.26.2-pg18 + container_name: dofek-db + restart: unless-stopped + ports: + - "127.0.0.1:5432:5432" + environment: + POSTGRES_DB: health + POSTGRES_USER: health + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U health -d health"] + interval: 5s + timeout: 5s + retries: 10 + + # ── Database Backups ─────────────────────────────────────── + db-backup: + image: prodrigestivill/postgres-backup-local:18 + container_name: dofek-db-backup + restart: unless-stopped + environment: + POSTGRES_HOST: db + POSTGRES_DB: health + POSTGRES_USER: health + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + SCHEDULE: "@daily" + BACKUP_KEEP_DAYS: 7 + BACKUP_KEEP_WEEKS: 4 + BACKUP_KEEP_MONTHS: 6 + volumes: + - db_backups:/backups + depends_on: + db: + condition: service_healthy + + # ── Redis ────────────────────────────────────────────────── + redis: + image: redis:7-alpine + container_name: dofek-redis + restart: unless-stopped + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + + # ── OpenTelemetry Collector ──────────────────────────────── + collector: + image: otel/opentelemetry-collector-contrib:0.149.0 + container_name: dofek-collector + restart: unless-stopped + user: "0:0" + env_file: /opt/dofek/.env.prod + volumes: + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - /opt/dofek/otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml:ro + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:13133/"] + interval: 15s + timeout: 5s + retries: 3 + + # ── Expo OTA Server ──────────────────────────────────────── + ota: + image: ghcr.io/axelmarciano/expo-open-ota:v2.3.16 + container_name: dofek-ota + restart: unless-stopped + env_file: /opt/dofek/.env.prod + environment: + BASE_URL: https://ota.dofek.asherlc.com + STORAGE_MODE: s3 + S3_BUCKET_NAME: dofek-ota + AWS_REGION: auto + CACHE_MODE: redis + REDIS_HOST: redis + REDIS_PORT: 6379 + PORT: 3000 + depends_on: + redis: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:3000/manifest 2>&1 | grep -q channel"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 10s + labels: + - traefik.enable=true + - traefik.http.routers.ota.rule=Host(`ota.dofek.asherlc.com`) + - traefik.http.routers.ota.entrypoints=websecure + - traefik.http.routers.ota.tls=true + - traefik.http.routers.ota.tls.certresolver=le + - traefik.http.services.ota.loadbalancer.server.port=3000 + + # ── Portainer (Docker management UI) ────────────────────── + portainer: + image: portainer/portainer-ce:2.25.1 + container_name: dofek-portainer + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - portainer_data:/data + labels: + - traefik.enable=true + - traefik.http.routers.portainer.rule=Host(`portainer.dofek.asherlc.com`) + - traefik.http.routers.portainer.entrypoints=websecure + - traefik.http.routers.portainer.tls=true + - traefik.http.routers.portainer.tls.certresolver=le + - traefik.http.routers.portainer.middlewares=authentik-auth + - traefik.http.services.portainer.loadbalancer.server.port=9000 + + # ── Netdata (server health monitoring) ───────────────────── + netdata: + image: netdata/netdata:v2.5 + container_name: dofek-netdata + restart: unless-stopped + pid: host + cap_add: + - SYS_PTRACE + - SYS_ADMIN + security_opt: + - apparmor:unconfined + volumes: + - netdata_config:/etc/netdata + - netdata_lib:/var/lib/netdata + - netdata_cache:/var/cache/netdata + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /etc/os-release:/host/etc/os-release:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + labels: + - traefik.enable=true + - traefik.http.routers.netdata.rule=Host(`netdata.dofek.asherlc.com`) + - traefik.http.routers.netdata.entrypoints=websecure + - traefik.http.routers.netdata.tls=true + - traefik.http.routers.netdata.tls.certresolver=le + - traefik.http.routers.netdata.middlewares=authentik-auth + - traefik.http.services.netdata.loadbalancer.server.port=19999 + +volumes: + letsencrypt: + db_data: + db_backups: + redis_data: + job_files: + portainer_data: + netdata_config: + netdata_lib: + netdata_cache: diff --git a/deploy/dokploy-app/main.tf b/deploy/dokploy-app/main.tf deleted file mode 100644 index 97baae8fdd..0000000000 --- a/deploy/dokploy-app/main.tf +++ /dev/null @@ -1,67 +0,0 @@ -# Deploys a single Dokploy application (web or worker) to a new image tag. -# CI passes the SHA-tagged image after pushing to GHCR. -# -# Usage: terraform apply -var="image_tag=sha-abc123" -var="app_id=..." - -variable "dokploy_host" { - description = "Dokploy API base URL" - type = string -} - -variable "dokploy_api_key" { - description = "Dokploy API key" - type = string - sensitive = true -} - -variable "app_id" { - description = "Dokploy application ID to deploy" - type = string -} - -variable "image_tag" { - description = "SHA-tagged image to deploy (e.g. sha-abc123)" - type = string -} - -variable "registry" { - description = "Container registry" - type = string - default = "ghcr.io" -} - -variable "image_name" { - description = "Image name without tag" - type = string - default = "asherlc/dofek" -} - -locals { - full_image = "${var.registry}/${var.image_name}:${var.image_tag}" -} - -resource "terraform_data" "deploy" { - triggers_replace = [var.app_id, local.full_image] - - provisioner "local-exec" { - interpreter = ["bash", "-c"] - command = <<-EOT - set -euo pipefail - echo "Updating app ${var.app_id} image to ${local.full_image}..." - curl -fsSL -X POST "${var.dokploy_host}/api/trpc/application.update" \ - -H "x-api-key: $DOKPLOY_API_KEY" \ - -H "Content-Type: application/json" \ - -d "{\"json\":{\"applicationId\":\"${var.app_id}\",\"sourceType\":\"docker\",\"dockerImage\":\"${local.full_image}\"}}" - - echo "Deploying app ${var.app_id}..." - curl -fsSL -X POST "${var.dokploy_host}/api/trpc/application.deploy" \ - -H "x-api-key: $DOKPLOY_API_KEY" \ - -H "Content-Type: application/json" \ - -d "{\"json\":{\"applicationId\":\"${var.app_id}\"}}" - EOT - - environment = { - DOKPLOY_API_KEY = var.dokploy_api_key - } - } -} diff --git a/deploy/dokploy-compose/main.tf b/deploy/dokploy-compose/main.tf deleted file mode 100644 index 0abdf56bf7..0000000000 --- a/deploy/dokploy-compose/main.tf +++ /dev/null @@ -1,92 +0,0 @@ -# Renders the infra-compose.yml template with secrets. -# No providers needed — pure template rendering. - -variable "postgres_password" { - type = string - sensitive = true -} - -variable "axiom_api_token" { - type = string - sensitive = true -} - -variable "sentry_otlp_logs_endpoint" { - type = string -} - -variable "sentry_otlp_logs_auth" { - type = string - sensitive = true -} - -variable "r2_endpoint" { - type = string -} - -variable "r2_access_key_id" { - type = string - sensitive = true -} - -variable "r2_secret_access_key" { - type = string - sensitive = true -} - -variable "expo_app_id" { - type = string -} - -variable "expo_access_token" { - type = string - sensitive = true -} - -variable "ota_jwt_secret" { - type = string - sensitive = true -} - -variable "ota_public_key_b64" { - type = string - sensitive = true -} - -variable "ota_private_key_b64" { - type = string - sensitive = true -} - -variable "db_data_path" { - description = "Host path for DB data (empty = use Docker volume)" - type = string - default = "" -} - -variable "db_backup_path" { - description = "Host path for DB backups (empty = use Docker volume)" - type = string - default = "" -} - -output "compose_rendered" { - value = templatefile("${path.module}/../dokploy/infra-compose.yml", { - postgres_password = var.postgres_password - axiom_api_token = var.axiom_api_token - sentry_otlp_logs_endpoint = var.sentry_otlp_logs_endpoint - sentry_otlp_logs_auth = var.sentry_otlp_logs_auth - r2_endpoint = var.r2_endpoint - r2_access_key_id = var.r2_access_key_id - r2_secret_access_key = var.r2_secret_access_key - expo_app_id = var.expo_app_id - expo_access_token = var.expo_access_token - ota_jwt_secret = var.ota_jwt_secret - ota_public_key_b64 = var.ota_public_key_b64 - ota_private_key_b64 = var.ota_private_key_b64 - ota_domain = "ota.dofek.asherlc.com" - db_data_path = var.db_data_path - db_backup_path = var.db_backup_path - }) - sensitive = true -} diff --git a/deploy/dokploy/.gitignore b/deploy/dokploy/.gitignore deleted file mode 100644 index c08ba1c8ae..0000000000 --- a/deploy/dokploy/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -*.tfstate -*.tfstate.* -*.tfvars -!terraform.tfvars.example -.terraform/ -tfplan* diff --git a/deploy/dokploy/cloud-init.yml b/deploy/dokploy/cloud-init.yml deleted file mode 100644 index 29f333c3e6..0000000000 --- a/deploy/dokploy/cloud-init.yml +++ /dev/null @@ -1,25 +0,0 @@ -#cloud-config -# Installs Dokploy on a fresh Ubuntu 24.04 server. -# After boot, access Dokploy at http://:3000 to create admin account. -# Then run `terraform apply` on dokploy.tf to configure services. - -package_update: true - -packages: - - ca-certificates - - curl - -write_files: - - path: /opt/dofek/otel-collector-config.yaml - permissions: "0644" - content: | - ${indent(6, otel_collector_content)} - -runcmd: - # Install Dokploy (installs Docker + Docker Swarm + Dokploy) - - curl -sSL https://dokploy.com/install.sh | sh -%{ if db_data_path != "" ~} - # Ensure block storage mount dirs exist for TimescaleDB - - mkdir -p ${db_data_path} - - mkdir -p ${db_backup_path} -%{ endif ~} diff --git a/deploy/dokploy/infra-compose.yml b/deploy/dokploy/infra-compose.yml deleted file mode 100644 index b5980e9b66..0000000000 --- a/deploy/dokploy/infra-compose.yml +++ /dev/null @@ -1,131 +0,0 @@ -# Infrastructure services managed as a Dokploy Compose stack. -# App services (web, worker) are Dokploy-native — see dokploy.tf. -# -# This file is templated by Terraform. - -services: - db: - image: timescale/timescaledb:2.26.2-pg18 - container_name: dofek-db - restart: unless-stopped - networks: - - default - - dokploy-network - ports: - - "127.0.0.1:5432:5432" - environment: - POSTGRES_DB: health - POSTGRES_USER: health - POSTGRES_PASSWORD: ${postgres_password} - volumes: -%{ if db_data_path != "" ~} - - ${db_data_path}:/var/lib/postgresql/data -%{ else ~} - - db_data:/var/lib/postgresql/data -%{ endif ~} - healthcheck: - test: ["CMD-SHELL", "pg_isready -U health -d health"] - interval: 5s - timeout: 5s - retries: 10 - - db-backup: - image: prodrigestivill/postgres-backup-local:18 - container_name: dofek-db-backup - restart: unless-stopped - environment: - POSTGRES_HOST: db - POSTGRES_DB: health - POSTGRES_USER: health - POSTGRES_PASSWORD: ${postgres_password} - SCHEDULE: "@daily" - BACKUP_KEEP_DAYS: 7 - BACKUP_KEEP_WEEKS: 4 - BACKUP_KEEP_MONTHS: 6 - volumes: -%{ if db_backup_path != "" ~} - - ${db_backup_path}:/backups -%{ else ~} - - db_backups:/backups -%{ endif ~} - depends_on: - db: - condition: service_healthy - - redis: - image: redis:7-alpine - container_name: dofek-redis - restart: unless-stopped - networks: - - default - - dokploy-network - volumes: - - redis_data:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 5s - timeout: 3s - retries: 5 - - collector: - image: otel/opentelemetry-collector-contrib:0.149.0 - container_name: dofek-collector - restart: unless-stopped - user: "0:0" - networks: - - default - - dokploy-network - environment: - AXIOM_API_TOKEN: ${axiom_api_token} - SENTRY_OTLP_LOGS_ENDPOINT: ${sentry_otlp_logs_endpoint} - SENTRY_OTLP_LOGS_AUTH: ${sentry_otlp_logs_auth} - volumes: - - /var/lib/docker/containers:/var/lib/docker/containers:ro - - /opt/dofek/otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml:ro - healthcheck: - test: ["CMD", "wget", "-q", "--spider", "http://localhost:13133/"] - interval: 15s - timeout: 5s - retries: 3 - - ota: - image: ghcr.io/axelmarciano/expo-open-ota:v2.3.16 - container_name: dofek-ota - restart: unless-stopped - environment: - BASE_URL: https://${ota_domain} - STORAGE_MODE: s3 - S3_BUCKET_NAME: dofek-ota - AWS_REGION: auto - AWS_BASE_ENDPOINT: ${r2_endpoint} - AWS_ACCESS_KEY_ID: ${r2_access_key_id} - AWS_SECRET_ACCESS_KEY: ${r2_secret_access_key} - EXPO_APP_ID: ${expo_app_id} - EXPO_ACCESS_TOKEN: ${expo_access_token} - JWT_SECRET: ${ota_jwt_secret} - KEYS_STORAGE_TYPE: environment - PUBLIC_EXPO_KEY_B64: ${ota_public_key_b64} - PRIVATE_EXPO_KEY_B64: ${ota_private_key_b64} - CACHE_MODE: redis - REDIS_HOST: redis - REDIS_PORT: 6379 - PORT: 3000 - depends_on: - redis: - condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "wget -qO- http://localhost:3000/manifest 2>&1 | grep -q 'channel'"] - interval: 10s - timeout: 5s - retries: 3 - start_period: 10s - -networks: - dokploy-network: - external: true - -volumes: - db_data: - db_backups: - redis_data: - diff --git a/deploy/dokploy/managed-secret-keys.json b/deploy/dokploy/managed-secret-keys.json deleted file mode 100644 index 8a52e45107..0000000000 --- a/deploy/dokploy/managed-secret-keys.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "apps": { - "web": [ - "GEMINI_API_KEY", - "MISTRAL_API_KEY", - "SLACK_APP_TOKEN", - "SLACK_BOT_TOKEN", - "SLACK_CLIENT_ID", - "SLACK_CLIENT_SECRET" - ], - "worker": [ - "GEMINI_API_KEY", - "MISTRAL_API_KEY", - "SLACK_APP_TOKEN", - "SLACK_BOT_TOKEN", - "SLACK_CLIENT_ID", - "SLACK_CLIENT_SECRET" - ] - }, - "protectedDestinationKeys": [ - "DATABASE_URL", - "REDIS_URL", - "NODE_ENV", - "PORT", - "PUBLIC_URL", - "OTEL_*" - ] -} diff --git a/deploy/dokploy/setup.sh b/deploy/dokploy/setup.sh deleted file mode 100755 index e44288b18c..0000000000 --- a/deploy/dokploy/setup.sh +++ /dev/null @@ -1,221 +0,0 @@ -#!/bin/bash -# Set up Dokploy project, apps, compose stack, and domains via the tRPC API. -# Replaces the broken Terraform Dokploy provider. -# -# Reads all config from terraform.tfvars (gitignored) or environment variables. -# Usage: ./setup.sh -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - -# --- Read config from terraform.tfvars or environment --- -get_var() { - local key="$1" - local tfvars_file="$SCRIPT_DIR/terraform.tfvars" - if [ ! -f "$tfvars_file" ]; then - echo "" - return 0 - fi - grep "^${key} " "$tfvars_file" | head -1 | sed 's/.*= "//;s/"$//' -} - -DOKPLOY_HOST="${DOKPLOY_HOST:-$(get_var dokploy_host)}" -API_KEY="${DOKPLOY_API_KEY:-$(get_var dokploy_api_key)}" -DOMAIN="dofek.asherlc.com" -OTA_DOMAIN="ota.dofek.asherlc.com" -GHCR_IMAGE="ghcr.io/asherlc/dofek:latest" - -if [ -z "${DOKPLOY_HOST}" ]; then - echo "Error: DOKPLOY_HOST is not set. Export DOKPLOY_HOST or define dokploy_host in $SCRIPT_DIR/terraform.tfvars." >&2 - exit 1 -fi - -if [ -z "${API_KEY}" ]; then - echo "Error: DOKPLOY_API_KEY is not set. Export DOKPLOY_API_KEY or define dokploy_api_key in $SCRIPT_DIR/terraform.tfvars." >&2 - exit 1 -fi - -POSTGRES_PASSWORD="$(get_var postgres_password)" -AXIOM_API_TOKEN="$(get_var axiom_api_token)" -SENTRY_OTLP_LOGS_ENDPOINT="$(get_var sentry_otlp_logs_endpoint)" -SENTRY_OTLP_LOGS_AUTH="$(get_var sentry_otlp_logs_auth)" -GHCR_TOKEN="$(get_var ghcr_token)" -R2_ENDPOINT="$(get_var r2_endpoint)" -R2_ACCESS_KEY_ID="$(get_var r2_access_key_id)" -R2_SECRET_ACCESS_KEY="$(get_var r2_secret_access_key)" -EXPO_APP_ID="$(get_var expo_app_id)" -EXPO_ACCESS_TOKEN="$(get_var expo_access_token)" -OTA_JWT_SECRET="$(get_var ota_jwt_secret)" -OTA_PUBLIC_KEY_B64="$(get_var ota_public_key_b64)" -OTA_PRIVATE_KEY_B64="$(get_var ota_private_key_b64)" - -# --- Helper --- -dokploy() { - local endpoint="$1" - shift - curl -fsSL "${DOKPLOY_HOST}/api/trpc/${endpoint}" \ - -H "x-api-key: ${API_KEY}" \ - -H "Content-Type: application/json" \ - "$@" -} - -echo "=== Dokploy Setup ===" - -# --- 1. Create project --- -echo "Creating project..." -PROJECT_RESULT=$(dokploy "project.create" -d '{"json":{"name":"dofek","description":"Health data pipeline"}}') -PROJECT_ID=$(echo "$PROJECT_RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin)['result']['data']['json']['project']['projectId'])") -ENV_ID=$(echo "$PROJECT_RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin)['result']['data']['json']['environment']['environmentId'])") -echo " Project: $PROJECT_ID" -echo " Environment: $ENV_ID" - -# --- 2. Create GHCR registry --- -echo "Creating GHCR registry..." -REG_RESULT=$(dokploy "registry.create" -d "{\"json\":{\"registryName\":\"GHCR\",\"registryType\":\"cloud\",\"registryUrl\":\"ghcr.io\",\"username\":\"asherlc\",\"password\":\"${GHCR_TOKEN}\",\"imagePrefix\":\"ghcr.io/asherlc\"}}") -REG_ID=$(echo "$REG_RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin)['result']['data']['json']['registryId'])") -echo " Registry: $REG_ID" - -# --- 3. Build shared env vars --- -APP_ENV_LINES=$(sed -n '/^app_env = {$/,/^}$/p' "$SCRIPT_DIR/terraform.tfvars" | grep '=' | grep -v '^app_env' | grep -v '^}' | sed 's/^ //;s/ = /=/;s/^"//;s/"$//' || true) - -build_env() { - local service_name="$1" - local extra_env="$2" - local env_str="" - - env_str+="DATABASE_URL=postgres://health:${POSTGRES_PASSWORD}@db:5432/health\n" - env_str+="REDIS_URL=redis://redis:6379\n" - env_str+="NODE_ENV=production\n" - env_str+="PUBLIC_URL=https://${DOMAIN}\n" - env_str+="OTEL_SERVICE_NAME=${service_name}\n" - env_str+="OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318\n" - env_str+="OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=http://collector:4318/v1/logs\n" - - while IFS='=' read -r key value; do - [ -z "$key" ] && continue - value="${value#\"}" - value="${value%\"}" - env_str+="${key}=${value}\n" - done <<< "$APP_ENV_LINES" - - if [ -n "$extra_env" ]; then - env_str+="$extra_env" - fi - - printf '%b' "$env_str" -} - -# --- 4. Create web application --- -echo "Creating web application..." -WEB_ENV=$(build_env "dofek-web" "PORT=3000\nJOB_FILES_DIR=/app/job-files\n") - -WEB_RESULT=$(dokploy "application.create" -d "{\"json\":{\"name\":\"dofek-web\",\"environmentId\":\"${ENV_ID}\"}}") -WEB_ID=$(echo "$WEB_RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin)['result']['data']['json']['applicationId'])") -echo " Web app: $WEB_ID" - -echo " Configuring web app..." -dokploy "application.update" -d "{\"json\":{\"applicationId\":\"${WEB_ID}\",\"sourceType\":\"docker\",\"dockerImage\":\"${GHCR_IMAGE}\"}}" > /dev/null -dokploy "application.update" -d "{\"json\":{\"applicationId\":\"${WEB_ID}\",\"command\":\"./entrypoint.sh web\"}}" > /dev/null -dokploy "application.update" -d "{\"json\":{\"applicationId\":\"${WEB_ID}\",\"registryId\":\"${REG_ID}\"}}" > /dev/null -python3 -c " -import json, sys -env = sys.stdin.read() -payload = json.dumps({'json': {'applicationId': '${WEB_ID}', 'env': env}}) -sys.stdout.write(payload) -" <<< "$WEB_ENV" | dokploy "application.update" -d @- > /dev/null - -# --- 5. Create worker application --- -echo "Creating worker application..." -WORKER_ENV=$(build_env "dofek-worker" "") - -WORKER_RESULT=$(dokploy "application.create" -d "{\"json\":{\"name\":\"dofek-worker\",\"environmentId\":\"${ENV_ID}\"}}") -WORKER_ID=$(echo "$WORKER_RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin)['result']['data']['json']['applicationId'])") -echo " Worker app: $WORKER_ID" - -echo " Configuring worker app..." -dokploy "application.update" -d "{\"json\":{\"applicationId\":\"${WORKER_ID}\",\"sourceType\":\"docker\",\"dockerImage\":\"${GHCR_IMAGE}\"}}" > /dev/null -dokploy "application.update" -d "{\"json\":{\"applicationId\":\"${WORKER_ID}\",\"command\":\"./entrypoint.sh worker\"}}" > /dev/null -dokploy "application.update" -d "{\"json\":{\"applicationId\":\"${WORKER_ID}\",\"registryId\":\"${REG_ID}\"}}" > /dev/null -python3 -c " -import json, sys -env = sys.stdin.read() -payload = json.dumps({'json': {'applicationId': '${WORKER_ID}', 'env': env}}) -sys.stdout.write(payload) -" <<< "$WORKER_ENV" | dokploy "application.update" -d @- > /dev/null - -# --- 6. Create infra compose stack --- -echo "Creating infra compose stack..." - -COMPOSE_CONTENT=$(cat "$SCRIPT_DIR/infra-compose.yml" \ - | sed "s/\${postgres_password}/${POSTGRES_PASSWORD}/g" \ - | sed "s/\${axiom_api_token}/${AXIOM_API_TOKEN}/g" \ - | sed "s|\${sentry_otlp_logs_endpoint}|${SENTRY_OTLP_LOGS_ENDPOINT}|g" \ - | sed "s|\${sentry_otlp_logs_auth}|${SENTRY_OTLP_LOGS_AUTH}|g" \ - | sed "s|\${r2_endpoint}|${R2_ENDPOINT}|g" \ - | sed "s/\${r2_access_key_id}/${R2_ACCESS_KEY_ID}/g" \ - | sed "s|\${r2_secret_access_key}|${R2_SECRET_ACCESS_KEY}|g" \ - | sed "s/\${expo_app_id}/${EXPO_APP_ID}/g" \ - | sed "s/\${expo_access_token}/${EXPO_ACCESS_TOKEN}/g" \ - | sed "s/\${ota_jwt_secret}/${OTA_JWT_SECRET}/g" \ - | sed "s|\${ota_public_key_b64}|${OTA_PUBLIC_KEY_B64}|g" \ - | sed "s|\${ota_private_key_b64}|${OTA_PRIVATE_KEY_B64}|g" \ - | sed "s/\${ota_domain}/${OTA_DOMAIN}/g" \ - | sed '/^%{/d' \ - | grep -v '^$' \ -) -# Remove lines with empty volume specs (Terraform conditionals leave empty paths) -COMPOSE_CONTENT=$(echo "$COMPOSE_CONTENT" | grep -v '^\s*- :/') - -COMPOSE_ESCAPED=$(echo "$COMPOSE_CONTENT" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))") - -COMPOSE_RESULT=$(dokploy "compose.create" -d "{\"json\":{\"name\":\"dofek-infra\",\"environmentId\":\"${ENV_ID}\"}}") -COMPOSE_ID=$(echo "$COMPOSE_RESULT" | python3 -c "import json,sys; print(json.load(sys.stdin)['result']['data']['json']['composeId'])") -echo " Compose: $COMPOSE_ID" - -echo " Setting compose file content..." -dokploy "compose.update" -d "{\"json\":{\"composeId\":\"${COMPOSE_ID}\",\"sourceType\":\"raw\",\"composeFile\":${COMPOSE_ESCAPED}}}" > /dev/null - -# --- 7. Create domains --- -echo "Creating domains..." -for host in "$DOMAIN" "dofek.fit" "www.dofek.fit" "dofek.live" "www.dofek.live"; do - echo " Domain: $host -> web:3000" - dokploy "domain.create" -d "{\"json\":{\"applicationId\":\"${WEB_ID}\",\"host\":\"${host}\",\"port\":3000,\"https\":true,\"certificateType\":\"letsencrypt\"}}" > /dev/null || echo " (may already exist)" -done - -echo " Domain: $OTA_DOMAIN -> ota:3000" -dokploy "domain.create" -d "{\"json\":{\"composeId\":\"${COMPOSE_ID}\",\"host\":\"${OTA_DOMAIN}\",\"port\":3000,\"https\":true,\"certificateType\":\"letsencrypt\",\"serviceName\":\"ota\"}}" > /dev/null || echo " (may already exist)" - -# --- 8. Configure Dokploy settings --- -echo "Setting Dokploy dashboard domain..." -dokploy "settings.assignDomainServer" -d "{\"json\":{\"host\":\"dokploy.asherlc.com\",\"certificateType\":\"letsencrypt\",\"letsEncryptEmail\":\"asherlc@asherlc.com\",\"https\":true}}" > /dev/null - -echo "Enabling daily Docker cleanup..." -dokploy "settings.updateDockerCleanup" -d "{\"json\":{\"enableDockerCleanup\":true}}" > /dev/null - -# --- 9. Deploy --- -echo "" -echo "Deploying infra compose stack..." -dokploy "compose.deploy" -d "{\"json\":{\"composeId\":\"${COMPOSE_ID}\"}}" > /dev/null & - -echo "Deploying web application..." -dokploy "application.deploy" -d "{\"json\":{\"applicationId\":\"${WEB_ID}\"}}" > /dev/null & - -echo "Deploying worker application..." -dokploy "application.deploy" -d "{\"json\":{\"applicationId\":\"${WORKER_ID}\"}}" > /dev/null & - -wait -echo "" -echo "=== Setup Complete ===" -echo "" -echo "Resource IDs (save these for CI):" -echo " PROJECT_ID=$PROJECT_ID" -echo " WEB_APP_ID=$WEB_ID" -echo " WORKER_APP_ID=$WORKER_ID" -echo " COMPOSE_ID=$COMPOSE_ID" -echo " REGISTRY_ID=$REG_ID" -echo "" -echo "Next steps:" -echo " 1. Check deployment status at ${DOKPLOY_HOST}/dashboard/projects" -echo " 2. Update DNS to point at new server IP" -echo " 3. Add DOKPLOY_HOST, DOKPLOY_API_KEY, DOKPLOY_WEB_APP_ID, DOKPLOY_WORKER_APP_ID to Infisical" -echo " 4. Migrate database from old server" diff --git a/deploy/dokploy/terraform.tfvars.example b/deploy/dokploy/terraform.tfvars.example deleted file mode 100644 index abd0e00c49..0000000000 --- a/deploy/dokploy/terraform.tfvars.example +++ /dev/null @@ -1,47 +0,0 @@ -# --- Hetzner (main.tf) --- -hcloud_token = "" -ssh_public_key = "ssh-ed25519 AAAA..." -domain = "dofek.asherlc.com" - -# Optional: Hetzner block storage for DB data -# data_volume_size_gb = 20 - -# --- Dokploy (dokploy.tf) --- -dokploy_host = "https://dokploy.dofek.asherlc.com" -dokploy_api_key = "" - -# GHCR access -ghcr_token = "" - -# Database -postgres_password = "" - -# Observability -axiom_api_token = "" -sentry_otlp_traces_endpoint = "" -sentry_otlp_logs_endpoint = "" -sentry_otlp_logs_auth = "" - -# Slack notifications (optional) -slack_bot_token = "" - -# OTA update server -r2_endpoint = "https://.r2.cloudflarestorage.com" -r2_access_key_id = "" -r2_secret_access_key = "" -expo_app_id = "" -expo_access_token = "" -ota_jwt_secret = "" -ota_public_key_b64 = "" -ota_private_key_b64 = "" - -# Block storage paths (set if using Hetzner volume) -# db_data_path = "/mnt/HC_Volume_dofek-data/postgres" -# db_backup_path = "/mnt/HC_Volume_dofek-data/backups" - -# Non-secret app config (OAuth client IDs, etc.) -# app_env = { -# WAHOO_CLIENT_ID = "..." -# WHOOP_CLIENT_ID = "..." -# GARMIN_CONSUMER_KEY = "..." -# } diff --git a/deploy/dokploy/otel-collector-config.yaml b/deploy/otel-collector-config.yaml similarity index 100% rename from deploy/dokploy/otel-collector-config.yaml rename to deploy/otel-collector-config.yaml diff --git a/deploy/server/cloud-init.yml b/deploy/server/cloud-init.yml new file mode 100644 index 0000000000..4437cd9e32 --- /dev/null +++ b/deploy/server/cloud-init.yml @@ -0,0 +1,59 @@ +#cloud-config +# Installs Docker CE, docker-rollout, and Infisical CLI on a fresh Ubuntu 24.04 server. +# After boot, copy the deploy compose file and .env.prod, then `docker compose up -d`. + +package_update: true + +packages: + - ca-certificates + - curl + - gnupg + +write_files: + - path: /opt/dofek/otel-collector-config.yaml + permissions: "0644" + content: | + ${indent(6, otel_collector_content)} + + - path: /etc/docker/daemon.json + permissions: "0644" + content: | + { + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + } + } + + - path: /etc/cron.d/docker-cleanup + permissions: "0644" + content: | + 0 3 * * * root docker system prune -f --filter "until=72h" >> /var/log/docker-prune.log 2>&1 + +runcmd: + # Install Docker CE + - install -m 0755 -d /etc/apt/keyrings + - curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc + - chmod a+r /etc/apt/keyrings/docker.asc + - echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list + - apt-get update + - apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + + # Install docker-rollout plugin (shell script, not arch-specific) + - mkdir -p /usr/local/lib/docker/cli-plugins + - curl -fsSL "https://github.com/Wowu/docker-rollout/releases/download/v0.13/docker-rollout" -o /usr/local/lib/docker/cli-plugins/docker-rollout + - chmod +x /usr/local/lib/docker/cli-plugins/docker-rollout + + # Install Infisical CLI (explicit GPG key + apt repo) + - curl -fsSL https://artifacts.infisical.com/gpg/infisical-archive-keyring.asc -o /etc/apt/keyrings/infisical.asc + - chmod a+r /etc/apt/keyrings/infisical.asc + - echo "deb [signed-by=/etc/apt/keyrings/infisical.asc] https://artifacts.infisical.com/repos/apt $(. /etc/os-release && echo $VERSION_CODENAME) main" > /etc/apt/sources.list.d/infisical.list + - apt-get update + - apt-get install -y infisical + + # Create app directory + - mkdir -p /opt/dofek + + # Restart Docker to pick up daemon.json log settings + - systemctl restart docker diff --git a/deploy/dokploy/main.tf b/deploy/server/main.tf similarity index 62% rename from deploy/dokploy/main.tf rename to deploy/server/main.tf index a76992f495..a231a31162 100644 --- a/deploy/dokploy/main.tf +++ b/deploy/server/main.tf @@ -1,9 +1,5 @@ -# Hetzner server provisioned with Dokploy pre-installed. -# Replaces the old main.tf which installed raw Docker + Compose. -# -# Dokploy service configuration (project, apps, domains, etc.) is managed -# by setup.sh via the Dokploy tRPC API — not Terraform — because the -# community Dokploy TF provider is incompatible with Dokploy v0.28.8. +# Hetzner server provisioned with Docker + docker-rollout. +# No PaaS layer — just Docker CE, the rollout plugin, and Infisical CLI. terraform { required_providers { @@ -23,11 +19,6 @@ variable "ssh_public_key" { type = string } -variable "domain" { - description = "Primary domain (e.g. dofek.asherlc.com)" - type = string -} - variable "ssh_allowed_ips" { description = "CIDR blocks allowed to SSH" type = list(string) @@ -69,7 +60,7 @@ resource "hcloud_firewall" "dofek" { source_ips = var.ssh_allowed_ips } - # HTTP/HTTPS for Traefik (Dokploy's reverse proxy) + # HTTP/HTTPS for Traefik rule { direction = "in" protocol = "tcp" @@ -83,18 +74,10 @@ resource "hcloud_firewall" "dofek" { port = "443" source_ips = ["0.0.0.0/0", "::/0"] } - - # Dokploy dashboard (only during initial setup, then behind Traefik) - rule { - direction = "in" - protocol = "tcp" - port = "3000" - source_ips = var.ssh_allowed_ips - } } resource "hcloud_server" "dofek" { - name = "dofek-dokploy" + name = "dofek" image = "ubuntu-24.04" server_type = "cax11" location = "nbg1" @@ -102,10 +85,7 @@ resource "hcloud_server" "dofek" { firewall_ids = [hcloud_firewall.dofek.id] user_data = templatefile("${path.module}/cloud-init.yml", { - domain = var.domain - otel_collector_content = file("${path.module}/otel-collector-config.yaml") - db_data_path = local.data_volume_mountpoint != "" ? "${local.data_volume_mountpoint}/postgres" : "" - db_backup_path = local.data_volume_mountpoint != "" ? "${local.data_volume_mountpoint}/backups" : "" + otel_collector_content = file("${path.module}/../otel-collector-config.yaml") }) } @@ -126,8 +106,3 @@ output "server_ip" { output "server_ipv6" { value = hcloud_server.dofek.ipv6_address } - -output "dokploy_url" { - description = "Dokploy dashboard URL (use this for initial setup, then configure a domain)" - value = "http://${hcloud_server.dofek.ipv4_address}:3000" -} diff --git a/deploy/terraform.tfvars.example b/deploy/terraform.tfvars.example index ad64c3adcf..12fb795e1e 100644 --- a/deploy/terraform.tfvars.example +++ b/deploy/terraform.tfvars.example @@ -1,8 +1,4 @@ -hcloud_token = "your-hetzner-api-token" -ssh_public_key = "ssh-ed25519 AAAA..." -domain = "dofek.example.com" -ghcr_username = "your-github-username" -ghcr_token = "ghp_..." -infisical_token = "st...." # Infisical service token or machine identity token +hcloud_token = "your-hetzner-api-token" +ssh_public_key = "ssh-ed25519 AAAA..." # ssh_allowed_ips = ["1.2.3.4/32"] # Restrict SSH to your IP (recommended) # data_volume_size_gb = 50 # Optional extra volume attached/mounted at /mnt/HC_Volume_dofek-data diff --git a/docker-compose.yml b/docker-compose.yml index ceb58ec8d9..4c48d0a6d0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,11 @@ +# Dev backing services. Run the app outside Docker for fast iteration. +# +# Usage: +# docker compose up -d +# pnpm dev # sync runner +# cd packages/web && pnpm dev # Vite dev server +# cd packages/server && pnpm dev # Express API server + services: db: image: timescale/timescaledb:latest-pg18 @@ -13,9 +21,6 @@ services: interval: 5s timeout: 5s retries: 10 - networks: - - dokploy-network - - default redis: image: redis:7-alpine @@ -27,9 +32,6 @@ services: interval: 5s timeout: 3s retries: 5 - networks: - - dokploy-network - - default collector: image: otel/opentelemetry-collector-contrib:0.149.0 @@ -40,51 +42,13 @@ services: SENTRY_OTLP_LOGS_ENDPOINT: ${SENTRY_OTLP_LOGS_ENDPOINT:-} volumes: - /var/lib/docker/containers:/var/lib/docker/containers:ro - - /opt/dofek/otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml:ro + - ./deploy/otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml:ro healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:13133/"] interval: 15s timeout: 5s retries: 3 - networks: - - dokploy-network - - default - - ota: - image: ghcr.io/axelmarciano/expo-open-ota:v2.3.16 - restart: unless-stopped - environment: - BASE_URL: https://ota.dofek.asherlc.com - STORAGE_MODE: s3 - S3_BUCKET_NAME: dofek-ota - AWS_REGION: auto - AWS_BASE_ENDPOINT: ${R2_ENDPOINT} - AWS_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID} - AWS_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY} - EXPO_APP_ID: ${EXPO_APP_ID} - EXPO_ACCESS_TOKEN: ${EXPO_ACCESS_TOKEN} - JWT_SECRET: ${OTA_JWT_SECRET} - KEYS_STORAGE_TYPE: environment - PUBLIC_EXPO_KEY_B64: ${OTA_PUBLIC_KEY_B64} - PRIVATE_EXPO_KEY_B64: ${OTA_PRIVATE_KEY_B64} - CACHE_MODE: redis - REDIS_HOST: redis - REDIS_PORT: 6379 - PORT: 3000 - depends_on: - redis: - condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "wget -qO- http://localhost:3000/manifest 2>&1 | grep -q channel"] - interval: 10s - timeout: 5s - retries: 3 - start_period: 10s volumes: db_data: redis_data: - -networks: - dokploy-network: - external: true diff --git a/scripts/ota-config.test.ts b/scripts/ota-config.test.ts index ca02acdb22..29d40b6b23 100644 --- a/scripts/ota-config.test.ts +++ b/scripts/ota-config.test.ts @@ -3,22 +3,22 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; const repoRoot = join(import.meta.dirname, ".."); -const dockerComposePath = join(repoRoot, "docker-compose.yml"); +const deployComposePath = join(repoRoot, "deploy", "docker-compose.deploy.yml"); const deployWorkflowPath = join(repoRoot, ".github", "workflows", "deploy.yml"); const deployOtaWorkflowPath = join(repoRoot, ".github", "workflows", "deploy-ota.yml"); const appJsonPath = join(repoRoot, "packages", "mobile", "app.json"); describe("OTA deployment config (expo-open-ota)", () => { - it("defines the ota service in docker-compose", () => { - const dockerCompose = readFileSync(dockerComposePath, "utf-8"); - expect(dockerCompose).toContain("ghcr.io/axelmarciano/expo-open-ota:"); - expect(dockerCompose).toContain("STORAGE_MODE: s3"); + it("defines the ota service in deploy compose", () => { + const deployCompose = readFileSync(deployComposePath, "utf-8"); + expect(deployCompose).toContain("ghcr.io/axelmarciano/expo-open-ota:"); + expect(deployCompose).toContain("STORAGE_MODE: s3"); }); it("fails the ota healthcheck when the manifest probe fails", () => { - const dockerCompose = readFileSync(dockerComposePath, "utf-8"); - expect(dockerCompose).toContain("wget -qO- http://localhost:3000/manifest"); - expect(dockerCompose).not.toContain("grep -q 'channel' || exit 0"); + const deployCompose = readFileSync(deployComposePath, "utf-8"); + expect(deployCompose).toContain("wget -qO- http://localhost:3000/manifest"); + expect(deployCompose).not.toContain("grep -q 'channel' || exit 0"); }); it("uses eoas publish in OTA deploy workflow", () => { diff --git a/scripts/sync-dokploy-env-from-infisical-lib.ts b/scripts/sync-dokploy-env-from-infisical-lib.ts deleted file mode 100644 index 9bad0e8c51..0000000000 --- a/scripts/sync-dokploy-env-from-infisical-lib.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { z } from "zod"; - -const infisicalSecretSchema = z.object({ - secretKey: z.string().min(1), - secretValue: z.string(), -}); - -const infisicalSecretListSchema = z.array(infisicalSecretSchema); - -const environmentAssignmentPattern = /^([A-Z0-9_]+)=(.*)$/; - -/** Quote a value for KEY=value format if it contains newlines or carriage returns. */ -function formatEnvValue(key: string, value: string): string { - if (value.includes("\n") || value.includes("\r")) { - const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - return `${key}="${escaped}"`; - } - return `${key}=${value}`; -} - -interface EnvironmentLine { - line: string; - key: string | null; - value: string | null; -} - -interface ParsedEnvironment { - hadTrailingNewline: boolean; - lines: EnvironmentLine[]; - keyIndexByName: Map; -} - -export interface BuildMergedEnvironmentOptions { - existingEnvironmentText: string; - infisicalSecrets: Map; - keysToSync: string[]; - failOnMissing?: boolean; - protectedDestinationKeys?: string[]; -} - -export interface BuildMergedEnvironmentResult { - environmentText: string; - changed: boolean; - updatedKeys: string[]; - addedKeys: string[]; - missingKeys: string[]; -} - -function parseEnvironment(existingEnvironmentText: string): ParsedEnvironment { - const hadTrailingNewline = existingEnvironmentText.endsWith("\n"); - const normalizedText = hadTrailingNewline - ? existingEnvironmentText.slice(0, Math.max(0, existingEnvironmentText.length - 1)) - : existingEnvironmentText; - - const rawLines = normalizedText.length === 0 ? [] : normalizedText.split("\n"); - - const lines: EnvironmentLine[] = []; - const keyIndexByName = new Map(); - - rawLines.forEach((line, lineIndex) => { - const assignmentMatch = environmentAssignmentPattern.exec(line); - if (!assignmentMatch) { - lines.push({ line, key: null, value: null }); - return; - } - - const key = assignmentMatch[1] ?? null; - const value = assignmentMatch[2] ?? null; - lines.push({ line, key, value }); - - if (key) { - const indexes = keyIndexByName.get(key); - if (!indexes) { - keyIndexByName.set(key, [lineIndex]); - } else { - indexes.push(lineIndex); - } - } - }); - - return { hadTrailingNewline, lines, keyIndexByName }; -} - -function isProtectedKey(key: string, protectedDestinationKeys: string[]): boolean { - return protectedDestinationKeys.some((protectedPattern) => { - if (protectedPattern.endsWith("*")) { - const protectedPrefix = protectedPattern.slice(0, Math.max(0, protectedPattern.length - 1)); - return key.startsWith(protectedPrefix); - } - return key === protectedPattern; - }); -} - -function serializeEnvironment(parsedEnvironment: ParsedEnvironment): string { - const serialized = parsedEnvironment.lines.map((line) => line.line).join("\n"); - if (serialized.length === 0) { - return ""; - } - if (parsedEnvironment.hadTrailingNewline) { - return `${serialized}\n`; - } - return serialized; -} - -export function parseInfisicalSecretsJson(infisicalSecretsJson: string): Map { - const parsedJson: unknown = JSON.parse(infisicalSecretsJson); - const parsedSecrets = infisicalSecretListSchema.parse(parsedJson); - return new Map(parsedSecrets.map((secret) => [secret.secretKey, secret.secretValue])); -} - -export function buildMergedEnvironment( - options: BuildMergedEnvironmentOptions, -): BuildMergedEnvironmentResult { - const failOnMissing = options.failOnMissing ?? true; - const protectedDestinationKeys = options.protectedDestinationKeys ?? []; - const parsedEnvironment = parseEnvironment(options.existingEnvironmentText); - const updatedKeys: string[] = []; - const addedKeys: string[] = []; - const missingKeys: string[] = []; - - for (const managedKey of options.keysToSync) { - if (isProtectedKey(managedKey, protectedDestinationKeys)) { - throw new Error(`Refusing to manage protected destination key "${managedKey}"`); - } - } - - for (const managedKey of options.keysToSync) { - const infisicalValue = options.infisicalSecrets.get(managedKey); - if (infisicalValue === undefined) { - missingKeys.push(managedKey); - continue; - } - - const existingIndexes = parsedEnvironment.keyIndexByName.get(managedKey) ?? []; - if (existingIndexes.length > 1) { - throw new Error(`Managed key "${managedKey}" appears multiple times in destination env`); - } - - const existingIndex = existingIndexes[0]; - if (existingIndex === undefined) { - parsedEnvironment.lines.push({ - line: formatEnvValue(managedKey, infisicalValue), - key: managedKey, - value: infisicalValue, - }); - addedKeys.push(managedKey); - continue; - } - - const existingLine = parsedEnvironment.lines[existingIndex]; - if (!existingLine || existingLine.value === null) { - throw new Error(`Expected parsable assignment line for managed key "${managedKey}"`); - } - - if (existingLine.value !== infisicalValue) { - existingLine.line = formatEnvValue(managedKey, infisicalValue); - existingLine.value = infisicalValue; - updatedKeys.push(managedKey); - } - } - - if (missingKeys.length > 0 && failOnMissing) { - throw new Error(`Missing managed keys in Infisical: ${missingKeys.join(", ")}`); - } - - const changed = updatedKeys.length > 0 || addedKeys.length > 0; - return { - environmentText: serializeEnvironment(parsedEnvironment), - changed, - updatedKeys, - addedKeys, - missingKeys, - }; -} diff --git a/scripts/sync-dokploy-env-from-infisical.test.ts b/scripts/sync-dokploy-env-from-infisical.test.ts deleted file mode 100644 index a6ef46cc99..0000000000 --- a/scripts/sync-dokploy-env-from-infisical.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildMergedEnvironment, - parseInfisicalSecretsJson, -} from "./sync-dokploy-env-from-infisical-lib.ts"; - -describe("parseInfisicalSecretsJson", () => { - it("parses key/value pairs from Infisical JSON output", () => { - const secrets = parseInfisicalSecretsJson( - JSON.stringify([ - { secretKey: "SLACK_CLIENT_ID", secretValue: "old-client-id" }, - { secretKey: "SLACK_CLIENT_SECRET", secretValue: "old-client-secret" }, - ]), - ); - - expect(secrets.get("SLACK_CLIENT_ID")).toBe("old-client-id"); - expect(secrets.get("SLACK_CLIENT_SECRET")).toBe("old-client-secret"); - }); -}); - -describe("buildMergedEnvironment", () => { - it("updates values for keys that already exist in the app env", () => { - const merged = buildMergedEnvironment({ - existingEnvironmentText: [ - "DATABASE_URL=postgres://example", - "SLACK_CLIENT_ID=old-client-id", - "SLACK_CLIENT_SECRET=old-client-secret", - ].join("\n"), - infisicalSecrets: new Map([ - ["SLACK_CLIENT_ID", "new-client-id"], - ["SLACK_CLIENT_SECRET", "new-client-secret"], - ["CLOUDFLARE_API_TOKEN", "cloudflare-token"], - ]), - keysToSync: ["SLACK_CLIENT_ID", "SLACK_CLIENT_SECRET"], - }); - - expect(merged.changed).toBe(true); - expect(merged.updatedKeys).toEqual(["SLACK_CLIENT_ID", "SLACK_CLIENT_SECRET"]); - expect(merged.addedKeys).toEqual([]); - expect(merged.environmentText).toContain("SLACK_CLIENT_ID=new-client-id"); - expect(merged.environmentText).toContain("SLACK_CLIENT_SECRET=new-client-secret"); - expect(merged.environmentText).not.toContain("CLOUDFLARE_API_TOKEN"); - }); - - it("adds missing keys when requested", () => { - const merged = buildMergedEnvironment({ - existingEnvironmentText: "DATABASE_URL=postgres://example\n", - infisicalSecrets: new Map([["SLACK_BOT_TOKEN", "xoxb-test"]]), - keysToSync: ["SLACK_BOT_TOKEN"], - }); - - expect(merged.changed).toBe(true); - expect(merged.updatedKeys).toEqual([]); - expect(merged.addedKeys).toEqual(["SLACK_BOT_TOKEN"]); - expect(merged.environmentText).toContain("SLACK_BOT_TOKEN=xoxb-test"); - }); - - it("does not report changes when target values already match", () => { - const merged = buildMergedEnvironment({ - existingEnvironmentText: "SLACK_APP_TOKEN=xapp-test\n", - infisicalSecrets: new Map([["SLACK_APP_TOKEN", "xapp-test"]]), - keysToSync: ["SLACK_APP_TOKEN"], - }); - - expect(merged.changed).toBe(false); - expect(merged.updatedKeys).toEqual([]); - expect(merged.addedKeys).toEqual([]); - }); - - it("throws when a managed key is missing from Infisical and failOnMissing is true", () => { - expect(() => - buildMergedEnvironment({ - existingEnvironmentText: "DATABASE_URL=postgres://example\n", - infisicalSecrets: new Map([["SLACK_CLIENT_ID", "client-id"]]), - keysToSync: ["SLACK_CLIENT_ID", "SLACK_CLIENT_SECRET"], - failOnMissing: true, - }), - ).toThrow("Missing managed keys in Infisical: SLACK_CLIENT_SECRET"); - }); - - it("throws when managed keys include protected destination keys", () => { - expect(() => - buildMergedEnvironment({ - existingEnvironmentText: "DATABASE_URL=postgres://example\n", - infisicalSecrets: new Map([["DATABASE_URL", "postgres://wrong-target"]]), - keysToSync: ["DATABASE_URL"], - protectedDestinationKeys: ["DATABASE_URL", "OTEL_*"], - }), - ).toThrow('Refusing to manage protected destination key "DATABASE_URL"'); - }); - - it("quotes values containing newlines", () => { - const pemValue = "-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----"; - const merged = buildMergedEnvironment({ - existingEnvironmentText: "DATABASE_URL=postgres://example\n", - infisicalSecrets: new Map([["PEM_KEY", pemValue]]), - keysToSync: ["PEM_KEY"], - }); - - expect(merged.changed).toBe(true); - expect(merged.addedKeys).toEqual(["PEM_KEY"]); - expect(merged.environmentText).toContain('PEM_KEY="-----BEGIN RSA PRIVATE KEY-----'); - expect(merged.environmentText).not.toContain("PEM_KEY=-----BEGIN"); - }); - - it("supports wildcard protected key patterns", () => { - expect(() => - buildMergedEnvironment({ - existingEnvironmentText: "OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318\n", - infisicalSecrets: new Map([["OTEL_EXPORTER_OTLP_ENDPOINT", "https://example.invalid"]]), - keysToSync: ["OTEL_EXPORTER_OTLP_ENDPOINT"], - protectedDestinationKeys: ["OTEL_*"], - }), - ).toThrow('Refusing to manage protected destination key "OTEL_EXPORTER_OTLP_ENDPOINT"'); - }); -}); diff --git a/scripts/sync-dokploy-env-from-infisical.ts b/scripts/sync-dokploy-env-from-infisical.ts deleted file mode 100644 index 7e14ecf959..0000000000 --- a/scripts/sync-dokploy-env-from-infisical.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { execFile } from "node:child_process"; -import { readFile } from "node:fs/promises"; -import { promisify } from "node:util"; -import { z } from "zod"; -import { - buildMergedEnvironment, - parseInfisicalSecretsJson, -} from "./sync-dokploy-env-from-infisical-lib.ts"; - -const execFileAsync = promisify(execFile); - -const managedKeysManifestSchema = z.object({ - apps: z.record(z.array(z.string().min(1))), - protectedDestinationKeys: z.array(z.string().min(1)).default([]), -}); - -const dokployApplicationOneResponseSchema = z.object({ - result: z.object({ - data: z.object({ - json: z.object({ - env: z.string().nullable().optional(), - }), - }), - }), -}); - -interface CliOptions { - environment: string; - apps: string[]; - managedKeysFile: string; - dryRun: boolean; - failOnMissing: boolean; -} - -interface DokployClientConfig { - host: string; - apiKey: string; -} - -interface AppExecutionConfig { - appName: string; - appId: string; - managedKeys: string[]; - protectedDestinationKeys: string[]; -} - -function parseBooleanFlag(value: string): boolean { - if (value === "true") return true; - if (value === "false") return false; - throw new Error(`Expected boolean value "true" or "false", received "${value}"`); -} - -function parseCliOptions(argv: string[]): CliOptions { - const defaults: CliOptions = { - environment: "prod", - apps: ["web", "worker"], - managedKeysFile: "deploy/dokploy/managed-secret-keys.json", - dryRun: false, - failOnMissing: true, - }; - - for (const argument of argv) { - if (argument === "--dry-run") { - defaults.dryRun = true; - continue; - } - - const [flag, value] = argument.split("=", 2); - if (!flag || value === undefined) { - throw new Error(`Invalid argument "${argument}"`); - } - - if (flag === "--environment") { - defaults.environment = value; - continue; - } - if (flag === "--apps") { - defaults.apps = value - .split(",") - .map((appName) => appName.trim()) - .filter((appName) => appName.length > 0); - continue; - } - if (flag === "--managed-keys-file") { - defaults.managedKeysFile = value; - continue; - } - if (flag === "--fail-on-missing") { - defaults.failOnMissing = parseBooleanFlag(value); - continue; - } - - throw new Error(`Unknown argument "${flag}"`); - } - - if (defaults.apps.length === 0) { - throw new Error("At least one app must be provided via --apps"); - } - - return defaults; -} - -function toDokployAppIdVariableName(appName: string): string { - const normalizedName = appName.replace(/[^A-Za-z0-9]/g, "_").toUpperCase(); - return `DOKPLOY_${normalizedName}_APP_ID`; -} - -function getRequiredEnvironmentVariable(name: string): string { - const value = process.env[name]; - if (!value || value.trim().length === 0) { - throw new Error(`Missing required environment variable ${name}`); - } - return value; -} - -async function loadInfisicalSecrets(environment: string): Promise> { - // Use npx with a pinned version to avoid curl|bash supply-chain risk. - // INFISICAL_TOKEN is passed via env (inherited by the child process) rather than - // --token CLI arg, so it won't leak in error messages or /proc/cmdline. - const { stdout } = await execFileAsync( - "npx", - ["@infisical/cli@0.43.72", "secrets", "--env", environment, "-o", "json", "--silent"], - { maxBuffer: 8 * 1024 * 1024 }, - ); - - return parseInfisicalSecretsJson(stdout); -} - -async function fetchDokployApplicationEnvironment( - dokployClientConfig: DokployClientConfig, - appId: string, -): Promise { - const input = encodeURIComponent(JSON.stringify({ json: { applicationId: appId } })); - const endpoint = `${dokployClientConfig.host}/api/trpc/application.one?input=${input}`; - const response = await fetch(endpoint, { - headers: { - "x-api-key": dokployClientConfig.apiKey, - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - throw new Error(`Dokploy application.one failed for app ${appId} with HTTP ${response.status}`); - } - - const responsePayload: unknown = await response.json(); - const parsedPayload = dokployApplicationOneResponseSchema.parse(responsePayload); - return parsedPayload.result.data.json.env ?? ""; -} - -async function updateDokployApplicationEnvironment( - dokployClientConfig: DokployClientConfig, - appId: string, - envText: string, -): Promise { - const endpoint = `${dokployClientConfig.host}/api/trpc/application.update`; - const response = await fetch(endpoint, { - method: "POST", - headers: { - "x-api-key": dokployClientConfig.apiKey, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - json: { - applicationId: appId, - env: envText, - }, - }), - }); - - if (!response.ok) { - throw new Error( - `Dokploy application.update failed for app ${appId} with HTTP ${response.status}`, - ); - } -} - -async function deployDokployApplication( - dokployClientConfig: DokployClientConfig, - appId: string, -): Promise { - const endpoint = `${dokployClientConfig.host}/api/trpc/application.deploy`; - const response = await fetch(endpoint, { - method: "POST", - headers: { - "x-api-key": dokployClientConfig.apiKey, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - json: { - applicationId: appId, - }, - }), - }); - - if (!response.ok) { - throw new Error( - `Dokploy application.deploy failed for app ${appId} with HTTP ${response.status}`, - ); - } -} - -async function loadManifest( - managedKeysFile: string, -): Promise> { - const manifestText = await readFile(managedKeysFile, "utf8"); - const manifestJson: unknown = JSON.parse(manifestText); - return managedKeysManifestSchema.parse(manifestJson); -} - -function buildAppExecutionConfigs( - selectedApps: string[], - manifest: z.infer, -): AppExecutionConfig[] { - return selectedApps.map((appName) => { - const managedKeys = manifest.apps[appName]; - if (!managedKeys) { - throw new Error(`App "${appName}" has no managed keys configured in manifest`); - } - - const appIdEnvironmentVariableName = toDokployAppIdVariableName(appName); - const appId = getRequiredEnvironmentVariable(appIdEnvironmentVariableName); - - return { - appName, - appId, - managedKeys, - protectedDestinationKeys: manifest.protectedDestinationKeys, - }; - }); -} - -async function runSync(): Promise { - const cliOptions = parseCliOptions(process.argv.slice(2)); - const triggerSource = process.env.GITHUB_EVENT_NAME ?? "manual"; - const triggerIdentifier = process.env.GITHUB_RUN_ID ?? "local"; - console.log( - `[secret-sync] start trigger=${triggerSource} run_id=${triggerIdentifier} env=${cliOptions.environment} dry_run=${String(cliOptions.dryRun)}`, - ); - - const dokployClientConfig: DokployClientConfig = { - host: getRequiredEnvironmentVariable("DOKPLOY_HOST"), - apiKey: getRequiredEnvironmentVariable("DOKPLOY_API_KEY"), - }; - - const manifest = await loadManifest(cliOptions.managedKeysFile); - const appExecutionConfigs = buildAppExecutionConfigs(cliOptions.apps, manifest); - const infisicalSecrets = await loadInfisicalSecrets(cliOptions.environment); - - const changedApps: string[] = []; - - for (const appExecutionConfig of appExecutionConfigs) { - console.log( - `[secret-sync] app=${appExecutionConfig.appName} app_id=${appExecutionConfig.appId} managed_keys=${appExecutionConfig.managedKeys.length}`, - ); - - const existingEnvironmentText = await fetchDokployApplicationEnvironment( - dokployClientConfig, - appExecutionConfig.appId, - ); - const mergeResult = buildMergedEnvironment({ - existingEnvironmentText, - infisicalSecrets, - keysToSync: appExecutionConfig.managedKeys, - failOnMissing: cliOptions.failOnMissing, - protectedDestinationKeys: appExecutionConfig.protectedDestinationKeys, - }); - - console.log( - `[secret-sync] app=${appExecutionConfig.appName} changed=${String(mergeResult.changed)} updated_keys=${mergeResult.updatedKeys.length} added_keys=${mergeResult.addedKeys.length} missing_keys=${mergeResult.missingKeys.length}`, - ); - - if (!mergeResult.changed) { - continue; - } - - changedApps.push(appExecutionConfig.appName); - if (cliOptions.dryRun) { - console.log(`[secret-sync] dry-run app=${appExecutionConfig.appName} apply=skipped`); - continue; - } - - await updateDokployApplicationEnvironment( - dokployClientConfig, - appExecutionConfig.appId, - mergeResult.environmentText, - ); - console.log(`[secret-sync] app=${appExecutionConfig.appName} env_updated=true`); - - await deployDokployApplication(dokployClientConfig, appExecutionConfig.appId); - console.log(`[secret-sync] app=${appExecutionConfig.appName} deployed=true`); - } - - console.log( - `[secret-sync] complete changed_apps=${changedApps.length > 0 ? changedApps.join(",") : "none"}`, - ); -} - -runSync().catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - console.error(`[secret-sync] failed: ${message}`); - process.exitCode = 1; -});