← All posts
· 5 min read ·
SalesforceCI/CDDevOpsScratch Orgs

Scratch Org Pools for Faster Salesforce CI

Scratch org creation takes 3–7 minutes and serializes your pipeline. Pre-creating a pool of orgs and checking them out on demand cuts that wait to near zero.

Server room infrastructure with racks of machines

Scratch org creation is the slowest step in most Salesforce CI pipelines. Each org takes 3–7 minutes to provision - and that’s before you push source, install dependencies, or run setup scripts. On a busy team with multiple PRs in flight, pipelines queue up and developers wait. The scratch org pool pattern fixes this by shifting the wait time out of the critical path.

Why Scratch Org Creation Blocks Pipelines

When a CI job runs sf org create scratch, it makes a synchronous API call to Salesforce’s infrastructure to provision a new org. There’s no way to speed up the provisioning itself - it’s constrained by Salesforce’s backend. In practice:

  • A simple org with no features enabled: ~3 minutes
  • An org with complex scratch org definition (many features, namespace): 5–7 minutes
  • An org for an ISV package with dependencies: 7–12 minutes

If your team runs 10 PRs a day and each pipeline takes 8 minutes (5 for org creation + 3 for tests), that’s 80 minutes of pure waiting. With a pool of 10 pre-created orgs ready to check out, pipeline start time drops to under 30 seconds.

The Pool Concept

A scratch org pool maintains a set of pre-created orgs tagged with their availability status:

Pool State Machine:
  [Creating] → [Available] → [In Use] → [Expired/Deleted]
                    ↑_______________|
                  (returned after use)

A background job (scheduled or webhook-triggered) keeps the pool topped up. When a pipeline needs an org, it checks out the first available one, runs its tests, and either returns the org to the pool (if clean) or deletes it.

Implementation with sfdx-hardis or Custom Scripts

Option 1: sfdx-hardis - the easiest path. The sfdx-hardis plugin includes pool management commands:

# Install the plugin
sf plugins install sfdx-hardis

# Create a pool of 10 orgs
sf hardis:scratch:pool:create \
  --config config/pool-scratch-def.json \
  --pool-size 10 \
  --tag "ci-pool"

# Fetch an available org from the pool
sf hardis:scratch:pool:fetch \
  --tag "ci-pool" \
  --target-dev-hub DevHub

Option 2: Custom shell scripts - gives you full control. The key insight is that org pools can be implemented using scratch org tags and custom metadata without any additional plugins.

Here’s a minimal pool management approach using org tags:

#!/usr/bin/env bash
# scripts/pool-fetch.sh - check out an available scratch org

set -euo pipefail

DEV_HUB="${1:-DevHub}"
POOL_TAG="ci-pool-available"

# Query for available orgs via the Dev Hub
AVAILABLE_ORG=$(sf data query \
  --query "SELECT ScratchOrg, SignupUsername FROM ActiveScratchOrg
           WHERE Description = '${POOL_TAG}'
           AND ExpirationDate > TODAY
           LIMIT 1" \
  --target-org "$DEV_HUB" \
  --json | jq -r '.result.records[0].SignupUsername // empty')

if [ -z "$AVAILABLE_ORG" ]; then
  echo "No available orgs in pool. Creating new scratch org..."
  sf org create scratch \
    --definition-file config/project-scratch-def.json \
    --target-dev-hub "$DEV_HUB" \
    --set-default \
    --alias ci-scratch \
    --duration-days 1
else
  echo "Checked out org: $AVAILABLE_ORG"
  # Mark as in-use by updating description
  sf data update record \
    --sobject ActiveScratchOrg \
    --where "SignupUsername='${AVAILABLE_ORG}'" \
    --values "Description='ci-pool-in-use'" \
    --target-org "$DEV_HUB"

  sf org login jwt \
    --username "$AVAILABLE_ORG" \
    --client-id "$SF_CLIENT_ID" \
    --jwt-key-file server.key \
    --set-default \
    --alias ci-scratch
fi

GitHub Actions Integration

Here’s a complete workflow that uses a pool for PR validation:

name: PR Validation

on:
  pull_request:
    branches: [main, develop]

jobs:
  validate:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - uses: actions/checkout@v4

      - name: Install Salesforce CLI
        run: npm install --global @salesforce/cli@latest

      - name: Install sfdx-hardis
        run: sf plugins install sfdx-hardis

      - name: Authenticate to Dev Hub
        run: |
          echo "${{ secrets.SF_JWT_KEY }}" > server.key
          sf org login jwt \
            --client-id ${{ secrets.SF_CLIENT_ID }} \
            --jwt-key-file server.key \
            --username ${{ secrets.SF_DEV_HUB_USERNAME }} \
            --alias DevHub \
            --set-default-dev-hub

      - name: Fetch org from pool
        id: fetch-org
        run: |
          sf hardis:scratch:pool:fetch \
            --tag ci-pool \
            --target-dev-hub DevHub \
            --alias ci-scratch
          echo "org-alias=ci-scratch" >> $GITHUB_OUTPUT

      - name: Push source
        run: |
          sf project deploy start \
            --target-org ci-scratch \
            --wait 20

      - name: Run Apex tests
        run: |
          sf apex run test \
            --target-org ci-scratch \
            --test-level RunLocalTests \
            --result-format human \
            --wait 20

      - name: Return org to pool (on success)
        if: success()
        run: |
          sf hardis:scratch:pool:return \
            --alias ci-scratch \
            --tag ci-pool

      - name: Delete org (on failure)
        if: failure()
        run: |
          sf org delete scratch \
            --target-org ci-scratch \
            --no-prompt

The critical pattern: return the org to the pool on success (so the next pipeline can use it), delete it on failure (a failed test run may have left the org in a dirty state).

Pool Maintenance: Keeping It Topped Up

Pools drain over time - orgs get used and deleted, and orgs expire if they hit their duration limit. A nightly job should refresh the pool:

# .github/workflows/pool-refresh.yml
name: Refresh Scratch Org Pool

on:
  schedule:
    - cron: '0 2 * * *'  # 2am daily
  workflow_dispatch:

jobs:
  refresh:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install CLI and authenticate
        run: |
          npm install --global @salesforce/cli@latest
          sf plugins install sfdx-hardis
          # ... auth steps

      - name: Delete expired orgs from pool
        run: sf hardis:scratch:pool:clean --tag ci-pool --target-dev-hub DevHub

      - name: Refill pool to target size
        run: |
          sf hardis:scratch:pool:create \
            --config config/project-scratch-def.json \
            --pool-size 10 \
            --tag ci-pool \
            --target-dev-hub DevHub

Gotchas

Daily scratch org limits. Your Dev Hub has a daily limit for scratch org creation (typically 200 for most editions). A pool of 10 orgs refreshed nightly + developer workflows can approach this limit on busy days. Monitor your active scratch org count in the Dev Hub.

Pool size vs pipeline concurrency. Your pool size should match your expected peak concurrent pipeline count, not your daily PR volume. If you run 30 PRs a day but never more than 5 simultaneously, a pool of 8 is sufficient (5 active + 3 buffer).

Source push time still matters. Even with a pre-created org, you still push source before running tests. For large orgs, this can be 5–10 minutes. Consider whether your pool orgs should include a base state with your installed packages and common metadata - this requires more complex pool management but pays off for large codebases.

Org data isolation. If your tests create or modify data, a returned org may have leftover data that affects the next test run. Either delete and recreate test data at the start of each run, or only return orgs that ran read-only test suites.

← All posts