Most Salesforce teams either have no Git workflow at all (change sets only) or they copied a software engineering branching model that doesn't account for org metadata dependencies. Here's a workflow tuned for Salesforce teams of any size.
Prerequisites
- Salesforce CLI (
sf) installed - Git installed and basic knowledge
- GitHub or GitLab for pull requests
- A Dev Hub org enabled for scratch orgs (optional but recommended)
Branch Strategy
main ← production-ready, protected
└── staging ← mirrors UAT/pre-prod org
└── feature/TICKET-123-account-validation
└── feature/TICKET-124-lwc-opportunities
└── hotfix/TICKET-130-broken-trigger
Rules:
- Direct commits to
mainare blocked - Every change goes through a PR targeting
stagingfirst stagingis merged tomainonly after UAT sign-off- Hotfixes branch from
main, merge back to bothmainandstaging
Project Structure
force-app/
main/
default/
classes/ ← Apex classes + test classes
triggers/ ← Apex triggers
lwc/ ← Lightning Web Components
objects/ ← Custom objects, fields, layouts
flows/ ← Screen flows, autolaunched flows
permissionsets/ ← Permission sets
profiles/ ← Profiles (minimal, only what changed)
.forceignore ← Like .gitignore for Salesforce metadata
sfdx-project.json ← Project definition
.forceignore — always include these:
# Don't track managed package metadata
**/profiles/*.profile # Profiles bloat fast — consider tracking only what changed
**/installedPackages/
**/objects/*/recordTypes/ # If org-specific
**/*.sharingRules
**/*.flowDefinition
Daily Developer Flow
# 1. Pull latest from staging
git checkout staging
git pull origin staging
# 2. Create feature branch
git checkout -b feature/TICKET-123-account-validation
# 3. Create scratch org for isolated development
sf org create scratch \
--definition-file config/project-scratch-def.json \
--alias ticket-123 \
--duration-days 7
# 4. Push source to scratch org
sf project deploy start --target-org ticket-123
# 5. Develop in the scratch org...
# 6. Retrieve changes back to local
sf project retrieve start \
--metadata ApexClass:AccountValidator \
--target-org ticket-123
# 7. Commit and push
git add force-app/main/default/classes/AccountValidator*
git commit -m "feat(TICKET-123): add account validation on before insert"
git push origin feature/TICKET-123-account-validation
# 8. Open PR targeting stagingPull Request Validation with GitHub Actions
# .github/workflows/validate-pr.yml
name: Validate PR
on:
pull_request:
branches: [staging]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Salesforce CLI
run: npm install -g @salesforce/cli
- name: Authenticate Dev Hub
run: |
echo "${{ secrets.SF_DEVHUB_AUTH_URL }}" > devhub.txt
sf org login sfdx-url --sfdx-url-file devhub.txt --alias devhub --set-default-dev-hub
- name: Create Scratch Org
run: |
sf org create scratch \
--definition-file config/project-scratch-def.json \
--alias ci-org \
--duration-days 1 \
--target-dev-hub devhub
- name: Deploy and Run Tests
run: |
sf project deploy start \
--source-dir force-app \
--target-org ci-org \
--test-level RunLocalTests \
--wait 30
- name: Delete Scratch Org
if: always()
run: sf org delete scratch --target-org ci-org --no-promptMerging to Production
# 1. Merge staging → main after UAT sign-off
git checkout main
git merge staging --no-ff -m "chore: merge UAT release 2026.05.01"
# 2. Tag the release
git tag -a v2026.05.01 -m "Release 2026.05.01"
git push origin main --tags
# 3. GitHub Actions auto-deploys main to production# .github/workflows/deploy-prod.yml
name: Deploy to Production
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install CLI
run: npm install -g @salesforce/cli
- name: Authenticate Production
run: |
echo "${{ secrets.SF_PROD_AUTH_URL }}" > prod.txt
sf org login sfdx-url --sfdx-url-file prod.txt --alias prod
- name: Deploy
run: |
sf project deploy start \
--source-dir force-app \
--target-org prod \
--test-level RunLocalTests \
--wait 60Common Pitfalls
- Tracking profiles in full: profiles in SFDX format are extremely noisy — only track the delta or use permission sets instead
- No scratch org definition: without
project-scratch-def.json, developers share dev orgs and step on each other's changes - Large PRs: keep feature branches focused on one ticket — a PR that changes 30 objects is impossible to review
- Merging without tests: the
--test-level RunLocalTestsflag on every deploy isn't optional in production