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.