Most Salesforce candidates can define CI/CD. Very few can draw the branch layout their last project used, say which sandbox each branch deployed to, and explain what happened when a deployment failed at 11 PM. That gap is what this post closes. It walks through the practical build: how a Salesforce team lays out branches, what maps to which sandbox, what goes in .gitignore, how a validation-only deploy runs on a pull request, how a quick deploy runs on merge, how deletions reach production, and the five pipeline failures that show up on almost every project. Commands use the current sf CLI.
Why source control is harder on Salesforce than on a normal codebase
On a Java or Node project, the repository is the truth. The running server is a copy of what the repository says. Salesforce starts from the opposite position, and this is the whole reason Salesforce DevOps is a separate skill.
The org is the source of truth by default. An admin logs into production, adds a field through Setup, edits a validation rule, changes a flow. Nothing touched Git. The org now contains metadata that no branch knows about. Until your team agrees that nothing changes in production except through the pipeline, your repository is a partial and slowly rotting picture of the org.
Metadata is XML, and Salesforce generates it. You do not write CustomObject files by hand. You retrieve them. That means the file content is produced by the platform, in the platform's order, at the platform's API version. Two developers retrieving the same object from two different sandboxes can produce files that differ in ways neither of them intended, and Git shows all of it as a change. Whitespace, element order and a bumped API version all look like real edits in a diff.
Two developers in one sandbox overwrite each other silently. This is the failure that surprises people moving from classic Salesforce admin work. In a shared developer sandbox, there is one copy of each Apex class and one copy of each layout. Developer A saves a class, Developer B saves the same class ten minutes later from a stale editor, and A's work is gone. There is no merge, no conflict, no warning. Git only protects you if the two developers are working in separate orgs and bringing changes back as separate commits. One developer per sandbox, or one scratch org per feature, is not a preference. It is what makes version control mean anything.
A fourth problem follows from these: deployment is not a file copy. Salesforce validates, compiles Apex, runs tests and checks dependencies before accepting anything. A merge that is clean in Git can still be rejected by the org. Your pipeline exists mainly to find that out before production does.
Mapping sandboxes to branches
The first design decision on any Salesforce DevOps project is which org each branch represents. Get this wrong and every later argument about merges is really an argument about orgs.
| Branch | Org it represents | Sandbox type usually used | Who deploys to it |
|---|---|---|---|
feature/* |
One developer's own org | Developer sandbox or scratch org | The developer, manually |
develop |
Shared integration org | Developer Pro | Pipeline, on every merge |
release/* |
UAT or staging org | Partial Copy or Full | Pipeline, when the branch is cut |
main |
Production | Production | Pipeline, on merge, with approval |
hotfix/* |
Hotfix org that matches production | Full sandbox, or a dedicated Dev sandbox refreshed from production | Pipeline or manual, urgently |
Three rules keep this honest.
One org per branch, and only one branch deploys into it. If both develop and a release branch deploy into the same QA sandbox, that sandbox stops telling you the truth about either.
The org a branch represents must be refreshed from the branch, not edited directly. The moment someone configures QA by hand to "just make the demo work", QA and the release branch have separated, and UAT sign-off means nothing.
Production is represented by main and by nothing else. Every path to production ends in a merge to main.
A branching model that works on Salesforce projects
The model below is close to standard Git Flow, trimmed for a platform where deployments are slow and validations are expensive.
feature/ABC-123-account-scoring is cut from develop. One work item, one branch. The developer works in their own sandbox or scratch org, retrieves changes into the branch, and commits. Branch names carrying the ticket ID matter more here than on other platforms, because when a deployment to production fails on a single component you need to find the work item behind that component fast.
develop is the integration line. Feature branches merge into it by pull request. Nothing commits to develop directly. Its job is to answer one question: do all the features in flight still deploy and pass tests together.
release/2026.03 is cut from develop when scope for a release is frozen. From that point, develop is open for the next release and the release branch takes only fixes. Those fixes merge into the release branch and are merged back into develop as well, otherwise the next release quietly reintroduces the bug you just fixed.
main receives the release branch when UAT signs off. The merge to main triggers the production deployment. Tag the merge commit with the release number so you can retrieve exactly what production had on any date.
hotfix/ABC-456-tax-rounding is cut from main, not from develop, because develop contains work that is not approved for production. It merges to main for the fix and into develop so the fix survives the next release.
So the merge directions, in full: feature to develop, develop to release, release to main, release back to develop, hotfix to main, hotfix back to develop. Six paths and no others. If someone is merging main into a feature branch to "get the latest", that is a rebase or a merge from develop, not from main.
On merge versus rebase: use merge for anything that has been pushed and shared, and keep rebase for tidying your own unpushed feature commits. Rebasing a shared branch rewrites history that other developers have already pulled, and on a Salesforce team where retrieved XML already produces noisy diffs, that confusion costs more than the clean history is worth.
One aside from the training floor. Candidates know the syntax and not the execution. They can recite git merge and git rebase and then cannot say which org the pipeline touched when the merge landed, or whether tests ran before or after. In a DevOps interview, the branch diagram is the easy half. Knowing what runs, where it runs and against which org is the half that gets you hired.
What belongs in .gitignore for a Salesforce repository
A Salesforce repository collects a lot of local state that must never be shared, starting with authentication files. Here is a working example.
# Salesforce CLI local state and cached org authentication
.sfdx/
.sf/
.localdevserver/
# Anything that can authenticate to an org
.env
*.key
*.pem
authFile.json
sfdx-auth-url.txt
# Node tooling for LWC Jest, ESLint and Prettier
node_modules/
coverage/
*.log
npm-debug.log*
# Editor and OS noise, but keep shared editor settings
.idea/
.DS_Store
Thumbs.db
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
# Temporary retrieve and deploy output
retrieve-output/
deploy-output/
**/unpackaged/
Note what is deliberately not ignored. force-app/ is the source and belongs in Git. So do sfdx-project.json, .forceignore, the manifest/ folder, your Jest and ESLint configuration and the CI workflow files. If a new joiner clones the repository and cannot run a deploy because a required config file was ignored, the ignore list is wrong.
The distinction candidates get asked about, and frequently confuse, is .gitignore versus .forceignore. .gitignore decides what Git tracks. .forceignore decides what the Salesforce CLI retrieves and deploys. They solve different problems and they are not interchangeable. A profile that you want in the repository for reference but never want pushed to an org belongs in .forceignore, not .gitignore.
The metadata that causes a merge conflict every single time
Some metadata types conflict on almost every parallel change, because Salesforce writes them as one large file that many features touch at once.
Profiles. A profile XML file contains field permissions, object permissions, page layout assignments, record type visibility, Apex class access and tab settings, for every object in the org. Two developers adding two unrelated fields both touch the same admin profile file. Worse, the file is order sensitive and incomplete: when you retrieve a profile, you get permissions only for the components included in the retrieve, and when you deploy it, you can overwrite permissions in the target org that were never in your file.
Permission sets. Better behaved than profiles because they are smaller and scoped, but still a shared file when several people extend the same permission set.
Page layouts. A layout is one file holding the full field arrangement. Two people adding fields to the same layout in two sandboxes produce a genuine conflict, and the merge tool cannot tell you which arrangement the business wanted.
Translations and custom labels. One file per language, and every feature that adds a label touches it. Guaranteed conflict on a multi-language project.
How teams avoid this:
- Move access control to permission sets and permission set groups, and keep profiles minimal. A profile that grants almost nothing conflicts almost never.
- Add profiles to
.forceignoreon most projects and manage them deliberately in one controlled work item, rather than letting every feature branch carry a profile diff. - Split layout work. Where two features must change the same layout, sequence them into one work item with one owner instead of merging two versions.
- For labels and translations, agree that the merge is manual and that both blocks are kept. These conflicts are almost always additive, and the fix is to accept both sides, not to pick one.
- Never resolve a metadata conflict by taking "theirs" wholesale on a profile or a layout. On code that is sometimes defensible. On a profile it silently removes permissions from live users.
Validation-only deploy on the pull request
The pull request is where you find out that a merge is deployable. The build authenticates to the target org, then runs a check-only deployment. Nothing is saved to the org, but Apex compiles, dependencies resolve and tests run.
CI authentication is normally JWT bearer flow with a connected app and a certificate, because it needs no interactive login.
# Authenticate the CI runner to the target org
sf org login jwt \
--client-id "$SF_CONSUMER_KEY" \
--jwt-key-file server.key \
--username "$SF_USERNAME" \
--instance-url https://login.salesforce.com \
--alias integration
For a pull request into develop, validate the whole source against the integration org:
# Check-only deploy: compiles and runs tests, saves nothing
sf project deploy start \
--source-dir force-app \
--dry-run \
--test-level RunLocalTests \
--target-org integration \
--wait 60
For a pull request into main, validate against production and keep the result, so the merge can use a quick deploy:
# Validation that produces a reusable deployment ID
sf project deploy validate \
--source-dir force-app \
--test-level RunLocalTests \
--target-org production \
--wait 90
sf project deploy validate is the command that matters for production. It performs the same checks as a real deployment and registers the result in the org, so the deployment does not have to be repeated from scratch at release time.
On a large org, validating the entire force-app folder on every pull request becomes too slow. The usual answer is a delta deployment: build a package containing only what the branch changed. The sfdx-git-delta plugin does this from the Git diff.
sf plugins install sfdx-git-delta
# Build a package.xml of only what changed on this branch
sf sgd source delta \
--from origin/develop \
--to HEAD \
--output-dir delta
sf project deploy validate \
--manifest delta/package/package.xml \
--test-level RunSpecifiedTests \
--tests AccountScoringTest OpportunitySyncTest \
--target-org production \
--wait 90
Delta deployments are fast and they are the reason most Salesforce pipelines stay usable. They also carry a risk covered in the failures section below, so most teams keep a nightly full validation against production as a safety net.
Quick deploy on merge
Once a validation against production has succeeded, the deployment itself does not need to run the tests again. That is a quick deploy, and it turns a ninety minute release window into a few minutes.
The validation returns a job ID. Capture it and use it after the merge.
# On merge to main, promote the already validated result
sf project deploy quick \
--job-id "$VALIDATION_JOB_ID" \
--target-org production \
--wait 60
If the pipeline validated and merged in the same run, you can skip storing the ID:
sf project deploy quick --use-most-recent --target-org production
Two conditions must hold for a quick deploy to be accepted. The validation must have run tests, so a validation with --test-level NoTestRun cannot be quick deployed. And the validation must be recent, because a validated result expires after ten days. Treat a validated deployment as perishable. If a release slips past that window, re-validate before the release call, not during it.
Check what happened with:
sf project deploy report --job-id "$VALIDATION_JOB_ID" --target-org production
Destructive changes: getting deletions to production
Deleting a file in Git deletes it from the branch. It does nothing to the org. A field you removed from source six months ago is still sitting in production, still on reports, still holding data. Salesforce deletes metadata only when you explicitly ask, using a destructive changes manifest.
You supply two files. destructiveChanges.xml lists what to remove, and it must be paired with a package.xml (which may be empty of members) describing the API version.
<?xml version="1.0" encoding="UTF-8"?>
<Package xmlns="http://soap.sforce.com/2006/04/metadata">
<types>
<members>Account.Legacy_Score__c</members>
<name>CustomField</name>
</types>
<types>
<members>OldAccountScoringBatch</members>
<name>ApexClass</name>
</types>
<version>62.0</version>
</Package>
Then deploy it. The flag decides ordering, and the ordering matters.
# Remove components after the rest of the package is deployed
sf project deploy start \
--manifest manifest/package.xml \
--post-destructive-changes manifest/destructiveChanges.xml \
--test-level RunLocalTests \
--target-org production \
--wait 60
Use --post-destructive-changes when the new components must exist before the old ones go, which is the normal case for a field rename or a class replacement. Use --pre-destructive-changes when the old component blocks the new one, for example when a deleted field frees up an API name that the new metadata wants to use.
Three practical points. Deletions are irreversible in production, and deleting a custom field deletes its data, so always run the destructive deployment as a validation first. Salesforce refuses to delete a component that something else still references, so clear the reports, layouts, flows and Apex that point at it before you try. And keep destructive changes in their own work item with its own approval, because reviewers read added lines carefully and skim deleted ones.
Five failures that actually break Salesforce pipelines
1. The deployment fails on Apex test coverage
The production deployment stops with a coverage error even though the developer's tests passed in their sandbox. Salesforce requires at least 75% code coverage across the org for a production deployment, and every trigger needs some coverage. A new class with no test, or a test that runs the code without asserting anything useful, will pass locally and block the release.
Fix. Validate with --test-level RunLocalTests on the pull request, not only on release day, so the coverage number is checked while the branch is still small. Enforce coverage per class in review rather than relying on the org-wide average, because one large well-tested class can hide three untested ones until the release that finally tips the number below 75.
2. Deleted metadata never leaves the org
The team removes an old field and its Apex, the pull request is merged, everything is green, and the field is still in production a year later. This is the most common silent failure in Salesforce pipelines, because nothing errors. The deployment simply ignores what is absent from the package.
Fix. Make destructive changes part of the work item that does the deleting. Where the team uses delta deployments, sfdx-git-delta can generate the destructive manifest from the same Git diff that produced the package, so deletions and additions travel together.
3. A profile deployment removes permissions from live users
A release goes out and on Monday morning a support team cannot see a field they have always used. Nobody removed that permission on purpose. A profile file in the branch was retrieved from a sandbox where the permission did not exist, and deploying it overwrote the production setting.
Fix. Keep profiles out of the pipeline. Put them in .forceignore, move access to permission sets, and where a profile genuinely must change, do it as a reviewed standalone change with the retrieved-before and deployed-after states compared.
4. A delta deployment is missing a dependency
The pull request validated cleanly, the merge deployed, and production throws an error because a layout references a field that was never included. Delta packages contain what changed in Git, which is not always what the org needs to accept the change. Record types, picklist values, field-level dependencies and components edited in the org but never retrieved are the usual gaps.
Fix. Run a full validation against production on a schedule, nightly or before each release, so drift and missing dependencies are found on a quiet day rather than during a release. And keep the rule that nothing is configured directly in an org that the pipeline owns, because most missing dependencies started as a change someone made in Setup and never retrieved.
5. The validation is stale by the time the deployment runs
The pipeline validated against production on Tuesday, the release call is on the following Friday week, and the quick deploy is rejected. Either the validated result has expired past its ten day life, or the target org changed after the validation and the result no longer describes reality. The same class of failure hits CI authentication: a JWT certificate quietly expires and every job starts failing at the login step.
Fix. Validate close to the deployment, and re-validate rather than argue with the error. Put the certificate expiry date in the team calendar with a reminder a month ahead. Have the pipeline fall back to a full sf project deploy start when the quick deploy is refused, so a stale validation costs you time and not the release.
A note on all five. Candidates cannot debug. In a DevOps interview, "your deployment to UAT failed, what do you check" separates people far more sharply than "what is CI/CD". The answer the interviewer wants is a sequence: read the actual component failure and line number in the deployment result, identify whether it is a test failure, a coverage number or a missing dependency, check whether the target org drifted, then reproduce against a sandbox before touching the pipeline.
What the interviewer is actually checking
- Whether you can draw your project's branch layout and name the org behind each branch. A model you cannot map to sandboxes is a model you read about rather than used.
- Whether you know what runs where. Validation-only against which org, tests at which level, quick deploy under which conditions. Knowing the commands without knowing the execution order reads as preparation, not experience.
- Whether you have met the metadata that fights back. Profiles, layouts and translations come up because anyone who has run a real Salesforce pipeline has been burned by them and will have an opinion.
- Whether you understand that deletion is a separate act. A candidate who mentions destructive changes without being prompted has clearly shipped a release.
- Whether you can debug a failure rather than describe a pipeline. The follow-up question is almost always about something breaking.
- Whether you can be specific. "Do you know CI/CD?" answered with "yes", followed by "where have you used it?" answered with silence, ends the round. Name the branch, the org, the tool and the failure you fixed. Specificity is what creates credibility.
Before your next interview
Pick one thing you have actually built, even a small one, and be able to walk it end to end: the ticket, the branch, the sandbox, the pull request, what the validation checked, what the merge deployed, and one thing that went wrong. Prepare that single story properly and it will carry more weight than a memorised list of Git commands, because every follow-up question has an answer behind it.
If you want to build that story on a real pipeline rather than describe one, our Salesforce training in Pune with placement support covers Git, branching, the sf CLI, DevOps Center and CI/CD pipelines as hands-on work with your own repository and orgs.