CI/CD on Salesforce: What Actually Works in Practice
I've set up CI/CD pipelines on Salesforce projects three times now — once at a government agency, once for an ISV partner, and once at a financial services org. Each time I learned something new, mostly about what doesn't work. This is the guide I wish I'd had the first time.
Forget Perfection, Start With the Basics
The Salesforce CI/CD ecosystem has a lot of tooling: sf cli, CumulusCI, Copado, Gearset, AutoRABIT. You can spend weeks evaluating tools. Don't. If you're building this from scratch, start with GitHub Actions (or GitLab CI, or Azure Pipelines — any standard CI platform) and the Salesforce CLI. You can always add a paid tool later. Most of what you need is achievable with shell scripts and sf commands.
The baseline pipeline I set up has three stages: validate, deploy to staging, and deploy to production. That's it. You can get fancy later, but if you don't have these three working reliably, nothing else matters.
Validation on Pull Requests
Every pull request triggers a validation job. The job spins up a scratch org (or uses a persistent dev sandbox — I'll get to that trade-off), deploys the changed metadata, runs the Apex tests, and reports back on the PR.
The key word there is "changed metadata." You do not want to deploy the entire repo on every PR. For any org of meaningful size, that takes 20-30 minutes and it will constantly break on things unrelated to the PR. This is where delta deployments come in.
I use sfdx-git-delta (the sgd plugin) to generate a package.xml that contains only the metadata that changed between the PR branch and the target branch. The validation job deploys just that delta. It brings PR validation down from 25 minutes to 3-5 minutes, and the failure messages are actually relevant to the changes in the PR.
Scratch Orgs vs. Sandboxes for Validation
In theory, scratch orgs are perfect for CI. They're disposable, consistent, and fast to create. In practice, they have limitations that you need to plan for.
Scratch orgs have a daily creation limit. If you have a busy team making 15-20 PRs a day, you'll hit the limit. You can mitigate this by pooling scratch orgs — creating a batch overnight and assigning them to CI jobs as needed — but that's added complexity.
Some metadata doesn't work in scratch orgs. Knowledge, certain managed package configurations, and some features that require org-wide setup can be painful to reproduce. If your project leans heavily on these, you might be better off validating against a persistent CI sandbox instead.
My current preference is a hybrid approach. PR validation runs against a persistent CI sandbox using check-only deployment (sf project deploy start --dry-run). It's fast, reliable, and doesn't consume scratch org limits. Scratch orgs are used for local development and for more thorough integration testing on the staging branch.
The Staging Deploy
When a PR merges to the main branch, the pipeline automatically deploys to a staging sandbox. This is a full deploy, not a delta — I want staging to always reflect the complete state of the main branch.
Why full deploy on staging but delta on PR validation? Because delta deployments can mask issues. If two PRs each work individually but conflict when combined, the delta validation on each PR passes but staging breaks. The full deploy to staging catches those integration issues.
The staging deploy also runs all local Apex tests. If tests fail, the pipeline posts to Slack and blocks the production deployment. In a healthy pipeline this almost never fails because the individual PRs were already validated, but it's your safety net.
Production Deployments
I don't auto-deploy to production. I know some teams do, and if you have the test coverage and the confidence, go for it. But in my experience — especially in government and financial services — a manual approval gate before production is non-negotiable.
The pipeline for production is triggered manually or on a tag. It does a check-only deployment first, runs the full test suite, and then waits for approval. Once approved, it quick-deploys using the validated deployment ID. This means production deployment takes seconds, not minutes, because the validation already happened.
Quick deploy is underrated. The sf project deploy quick command lets you deploy a previously validated package without re-running tests. For large orgs where the full test suite takes 30+ minutes, this is the difference between a five-minute deployment window and a two-hour one.
Handling Destructive Changes
This is the part nobody talks about. Adding metadata is easy. Deleting metadata — removing a field, renaming a class, deleting a Flow — requires a destructive changes manifest. sfdx-git-delta can generate this too, but you need to handle it carefully.
My rule: destructive changes always go through a manual review. The CI pipeline generates the destructive manifest and includes it in the PR diff, but a human reviews it before it's deployed. I've seen automated destructive deployments delete production fields that still had data in them. It's not worth the risk.
Test Data Strategy
Your CI pipeline is only as good as your test data. For scratch orgs, I maintain a set of test data scripts that run after org creation — they create the standard users, accounts, permission assignments, and reference data that the tests expect.
For sandbox validation, the CI sandbox has a standing dataset that gets refreshed monthly from production (with PII masked). This gives us realistic data volumes for query performance testing without the overhead of seeding data on every run.
What I've Learned the Hard Way
Cache your dependencies. If your pipeline installs sf plugins on every run, that's two minutes of wasted time per job. Use your CI platform's caching mechanism to cache node_modules and plugin installations.
Pin your Salesforce CLI version. The sf cli updates frequently and occasionally introduces breaking changes. Pin to a specific version in your pipeline and update deliberately, not accidentally.
Monitor your pipeline. Track validation times, failure rates, and flaky tests. If your pipeline takes more than ten minutes for PR validation, developers will stop waiting for it and merge without checking. Speed is a feature.
Start simple. My first pipeline had three stages and a shell script. My current one has parallel jobs, scratch org pooling, Slack notifications, and automated release notes. But I added each of those incrementally as the team needed them. If I'd tried to build the full version on day one, I'd have burned out before it was useful.
The goal of CI/CD on Salesforce isn't to achieve some DevOps ideal. It's to make deployments boring. Boring means predictable, and predictable means you can focus on building things instead of worrying about breaking things.
Liked this?
Get one Salesforce insight per week. No spam.