Field notes

One org, one container

The afternoon that keeps paying for itself.

I spent one afternoon building a Salesforce dev container that logs itself into the right org and can't be pointed at the wrong one. Every org I've picked up since has started from it.

Here's the problem it solved, what it looks like, what it costs, and the bug in the middle that nearly made me quit.

The problem: your home directory is the org

The Salesforce CLI stores every org you've ever logged into under ~/.sf and ~/.sfdx. One machine, one home directory, one pile of credentials. Which org a command hits is a pointer — target-org — that lives in a config file and is one sf config set away from being wrong.

Fine with one org. It stops being fine the moment a client has a Regression sandbox, a Dev Full, a UAT and production, and you work on all four from the same laptop.

Every terminal looks identical. The prompt doesn't tell you which org it's pointed at. You find out when a deploy lands somewhere you didn't mean, or when sf org display shows a username you weren't expecting.

And it isn't only auth that bleeds. The CLI version bleeds, so an upgrade for one project changes deploy behavior for all of them. Java for the Apex language server bleeds. Extension settings bleed. Onboarding someone means a half day of install Node, install Java 17, install the CLI, pin it to this version, install the code-analyzer plugin, log into each org. And the result still differs from your machine in ways nobody can see.

So the problem I wanted solved was narrow and boring: a terminal should know which org it belongs to, and it should be impossible to get that wrong by accident. Everything below follows from that one sentence.

What it looks like

One clone of the repo per org, each opened in its own dev container. The container has its own home directory, so it has its own ~/.sf. It can't see any other org's credentials because they aren't there.

The folder name is the key. VS Code's Dev Containers extension exposes it as ${localWorkspaceFolderBasename}, and I use it for both the container name and the names of the volumes holding the CLI's auth. Clone the same repo into acme-uat instead of acme-dev and you get a different container with different, empty volumes. Nothing in the repo changes.

// .devcontainer/devcontainer.json
{
  "name": "${localWorkspaceFolderBasename}",
  "build": { "dockerfile": "Dockerfile" },

  // Which org this clone talks to. .env is gitignored; created from
  // .env.example on first open so nothing tracked ever names an org.
  "initializeCommand": "test -f '${localWorkspaceFolder}/.devcontainer/.env' || cp '${localWorkspaceFolder}/.devcontainer/.env.example' '${localWorkspaceFolder}/.devcontainer/.env'",
  "runArgs": ["--env-file", "${localWorkspaceFolder}/.devcontainer/.env"],

  "features": {
    "ghcr.io/devcontainers/features/java:1": { "version": "17" }
  },
  "remoteUser": "node",

  // Org auth lives here. Named volumes keyed on the folder name:
  // a differently named clone gets its own, fully separate auth.
  "mounts": [
    "source=${localWorkspaceFolderBasename}-sf,target=/home/node/.sf,type=volume",
    "source=${localWorkspaceFolderBasename}-sfdx,target=/home/node/.sfdx,type=volume"
  ],

  "forwardPorts": [1717],
  "containerEnv": {
    "SF_WEB_OAUTH_SERVER_TIMEOUT": "300000",
    "NODE_OPTIONS": "--dns-result-order=ipv4first"
  },

  "postCreateCommand": "sudo chown -R node:node /home/node/.sf /home/node/.sfdx && npm ci && sf --version"
}

The two containerEnv lines and the forwarded port are login mechanics and get their own section. The volumes are the real point: auth survives a rebuild because it isn't in the container, it's in a volume the container mounts.

The .env file is two lines, and it's the only place an org is named:

# .devcontainer/.env  (gitignored; .env.example is the committed template)
DEV_ORG_ALIAS=acme-dev
DEV_ORG_INSTANCE_URL=https://acme--dev.sandbox.my.salesforce.com

The image stays deliberately small. Node, git and the usual tooling come from Microsoft's base image, Java comes from the feature above, and the only things I add are the CLI and the one plugin the Code Analyzer extension refuses to start without.

# .devcontainer/Dockerfile
FROM mcr.microsoft.com/devcontainers/typescript-node:<pinned-tag>

# Pinned. Bump deliberately -- CLI upgrades have changed deploy behaviour before.
RUN npm install -g @salesforce/cli@<pinned-version>

# Plugins install under the user's home, and the extension runs as node,
# so this has to happen after USER node or it lands in /root, invisible.
USER node
RUN mkdir -p /home/node/.sf /home/node/.sfdx
RUN sf plugins install code-analyzer@<pinned-version>

Why a VS Code extension needs a CLI plugin baked into the image, and how to fix containers that already exist, gets a field note of its own.

That pin is a feature, not laziness. Every container built from this file has exactly one CLI version. When I bump it, I bump it on purpose, in one commit, and note it in the deploy workbook.

Logging in without typing a command

I didn't want a login command to remember. I wanted to open the folder and have the container either tell me which org it's on, or open the browser so I could log in. And then never ask again.

That's a VS Code task with "runOn": "folderOpen". I tried it as a postStartCommand first. Doesn't work there: the devcontainer lifecycle hooks don't have VS Code's BROWSER bridge, which is the thing that opens a URL on the Mac instead of inside the container. A task gets a real terminal, and the bridge comes with it.

// .vscode/tasks.json
{
  "version": "2.0.0",
  "tasks": [{
    "label": "Authorize Salesforce org",
    "type": "shell",
    "command": "bash ${workspaceFolder}/.devcontainer/authorize-org.sh",
    "runOptions": { "runOn": "folderOpen" },
    "presentation": { "reveal": "always", "panel": "dedicated", "clear": true },
    "problemMatcher": []
  }]
}

The script checks the CLI's local org list for the alias from .env. If it's there, set it as default and print one line. If it isn't, open the browser.

# .devcontainer/authorize-org.sh (trimmed)
ALIAS="${DEV_ORG_ALIAS:-}"
INSTANCE_URL="${DEV_ORG_INSTANCE_URL:-}"

# --skip-connection-status keeps this off the network, so a normal
# start never waits on Salesforce just to decide there's nothing to do.
if sf org list --json --skip-connection-status | node -e '
      let raw = ""; process.stdin.on("data", c => raw += c).on("end", () => {
        const orgs = Object.values(JSON.parse(raw).result ?? {}).flat();
        process.exit(orgs.some(o => o.alias === process.argv[1]) ? 0 : 1);
      })' "$ALIAS"; then
  sf config set target-org "$ALIAS" >/dev/null
  echo "Salesforce org authorized: $ALIAS"
  exit 0
fi

echo "No Salesforce authorization found for \"$ALIAS\" - opening the login in your browser."
sf org login web --alias "$ALIAS" --instance-url "$INSTANCE_URL" --set-default

First time a container opens, the browser appears at the right login page (--instance-url picks test.salesforce.com, login.salesforce.com, or the org's My Domain, whichever .env says). Log in once. Every open after that, rebuilds included, prints:

Salesforce org authorized: acme-dev

in about a second.

That line is the entire safety mechanism. It's on screen every time the window opens, and it answers which org is this terminal? before anyone thinks to ask.

The IPv6 trap: the login that succeeded and timed out anyway

This is the part that cost the afternoon, and the reason this setup is worth writing down instead of rebuilding from memory.

Web login from inside a container has a known shape. sf org login web starts a tiny HTTP server on port 1717 inside the container and waits for Salesforce to redirect the browser back to http://localhost:1717/OauthRedirect with the auth code. The browser is on the Mac, so "forwardPorts": [1717] is what carries that redirect back in. Miss it and the login hangs after a password that was accepted.

Fine. I had the forward. It still hung. And then, several minutes later:

AuthTimeoutError: The authentication session timed out. Please try again.

Everything looked right. The Ports panel showed 1717 forwarded to 1717. curl http://host.docker.internal:1717/ from inside the container — out to the Mac and back through the forward — came back refused. So the forward was delivering to a port nothing was listening on.

Except something was:

$ ss -ltnp | grep 1717
LISTEN 0 511 [::1]:1717 [::]:*    users:(("node",pid=4127))

[::1].

The CLI binds its callback server to the name localhost, not to an address. Node resolves localhost to ::1 before 127.0.0.1, so the server came up on IPv6 loopback only. VS Code's port forward delivers over IPv4 127.0.0.1. Right port, wrong address family, connection refused, and the CLI sits there waiting out its timer after a login the browser had already reported as successful.

The fix is one environment variable:

"containerEnv": {
  "NODE_OPTIONS": "--dns-result-order=ipv4first",
  "SF_WEB_OAUTH_SERVER_TIMEOUT": "300000"
}

With IPv4 first, the server binds 127.0.0.1:1717, which is where the forward actually lands. It has to be containerEnv — container-wide — rather than a line in my script, because the Salesforce extension's own Authorize an Org command starts the same server and needs the same fix. The second variable widens the CLI's two-minute login window to five, which matters when the browser opens on a different desktop and you have to go find it.

The dead ends are worth listing, because every one of them looks like the answer:

  • SF_CONTAINER_MODE=true sounds exactly right. It makes sf org login web refuse to run and tell you to use device login instead.
  • sf org login device isn't an option anymore. Plain and simple. Half the blog posts about containers and the Salesforce CLI still recommend it.
  • Changing the port fails redirect_uri matching. The default connected app registers 1717 and nothing else.
  • JWT login works, and it's the right answer for CI. For a laptop it means a Connected App, a certificate, a private key kept out of the repo, and pre-authorized users. Too much.

The reason I'm spending this many words on a one-line fix: the failure is silent and delayed. The browser says success. The error shows up minutes later and blames a timeout. Nothing anywhere points at IPv6. The comment above that line in devcontainer.json is fourteen lines long and it is staying that way.

If you landed here from the error message rather than the top of the post, there's a short standalone version of this one: localhost isn't one address.

Switching orgs

One clone per org is the safe default. When two long-lived branches or two people are involved, two windows and two containers with nothing shared is exactly right.

But a lot of the time I'm alone, flipping between a client's dev sandbox and their UAT to promote something. For that, a second clone is more ceremony than it's worth. So each org gets a saved .env.<name> next to the template, and a script copies one over .env:

.devcontainer/
  .env.example     committed — the template
  .env             gitignored — the active org
  .env.dev         gitignored — saved orgs, one file each
  .env.uat
  .env.prod
$ .devcontainer/switch-org.sh
Org files in .devcontainer/ (* = matches the active .env):
  * dev              acme-dev
    uat              acme-uat
    prod             acme-prod

$ .devcontainer/switch-org.sh uat
Now targeting "uat": DEV_ORG_ALIAS=acme-uat
Rebuild to apply:  ⇧⌘P → Dev Containers: Rebuild Container

The script is a cp with guard rails: lists what you have, marks the active one with cmp -s, refuses example, tells you to rebuild. The rebuild is the only cost. .env is read once at container start via --env-file, so a change doesn't take effect until the container restarts.

Which is also why it's safe. You can't half-switch.

What makes this cheap is the volume design from earlier. Every org you've logged into from this clone lives in the same -sf volume under its own alias, so switching to UAT the second time doesn't open a browser. The authorize task finds the alias, sets it as default, prints the line. Switching back is the same.

The .gitignore rule that makes it safe is three lines:

.env
.env.*
!.env.example

Nothing tracked ever names an org, so a rebase or a git pull can't flip a clone to a different environment behind your back. Getting the org name out of tracked files was its own small project, which gets a field note of its own too.

Why bother

You can't deploy to the wrong org by accident. The container only has one org's credentials. The wrong org isn't a typo away, it's in a different window with a different title bar. This alone would have paid for the afternoon.

The Mac stays clean. No Node, no Java, no Salesforce CLI, no plugin versions on the host. Uninstalling a client is docker volume rm and deleting a folder.

Onboarding is clone, open, log in. New teammate, new laptop, machine after a wipe: the first build takes a few minutes and produces the same CLI, the same Java, the same extensions as everyone else. There's no setup document to keep current, because the setup is the repo.

Pins are real. A pinned CLI in the Dockerfile means every container on the project has that CLI. When a release changes deploy semantics, and they do, the whole team moves together in one reviewed commit, or not at all.

Rebuilds are free. Auth is in a volume, not the container. Change the Dockerfile, rebuild, and the org line prints the same second it did before. That removes the fear of touching the container, which is the thing that keeps it maintained.

The knowledge lives in the files. The IPv6 fix is a comment above the line that applies it. The port 1717 explanation is a comment above the forward. Nobody has to remember why. The file remembers.

What it costs, honestly

  • You need a container engine running. OrbStack on a Mac is light, but it's another thing that has to be up before you can work. Docker Desktop is heavier. Podman works in principle and I haven't proven it.
  • The first build is minutes, not seconds. A Node image, a Java feature, a global npm install of the CLI, a plugin install. Cached afterwards, and a rebuild touching only the last layer is quick, but the first open of a new clone is a coffee.
  • Switching an org means a rebuild. By design. Two clones side by side avoids it, at the cost of disk.
  • It's VS Code shaped. The devcontainer spec has a CLI and other editors support it to varying degrees, but the folder-open task, the BROWSER bridge and the port forwarding are the VS Code extension's behavior. Someone on a different editor gets the image and the volumes and has to solve login themselves.
  • You inherit a new class of bug. The IPv6 issue doesn't exist on a bare Mac. Neither does "the extension's Authorize command ignores the forwarded port," or "known_hosts is empty in a fresh container so the first git push over SSH prompts." Each one is small. Each one is a thing you now know that you didn't need to know before.
  • Apex tests are just as slow. A container changes nothing about the org. Twenty-five-minute test classes are still twenty-five minutes.

None of this has made me want to go back. But the first two are why I wouldn't push this on a one-org project with one developer. The isolation starts paying the moment there's a second org.

Tooling is invisible until it isn't

Nobody schedules tooling. It isn't a feature, it doesn't close a ticket, and when it works you can't see it. So it gets done the bad way: a little at a time, under pressure, by whoever happens to be stuck. The login gets fixed on one machine and never written down. The CLI gets pinned in a README nobody reads, because the same README still says to install Node 16. Every laptop is slightly different, and every difference is a bug waiting for a demo.

The tax isn't dramatic. It's small and constant. A minute spent checking which org you're in. An sf org display before every deploy because you're not sure. Twenty minutes for a new starter to work out why the Apex language server isn't running. An hour, once a quarter, when something lands in the wrong sandbox and has to be found and undone.

None of it ever feels like a problem worth stopping for. So nobody stops.

Here's the thing about paying it properly, once, when you actually have the time: the return isn't linear.

  • Every new org starts from a working container. That's not a saving of an afternoon per org. It's the afternoon plus the bug I would have hit again, because the IPv6 problem doesn't go away and I would not have remembered it.
  • Every new machine is a clone. A laptop replacement went from a day of setup to the length of one build.
  • Every new teammate gets my setup, not a description of it. Exactly, down to the CLI patch version, which means the first time they say "works for me" it's actually true.
  • Every fix lands in all of it at once. When I bumped the CLI, or added the code-analyzer plugin to the image because the extension refused to start without it, that was one commit and one rebuild per clone. Not one laptop at a time. Not a Slack message saying remember to run this.

That last one is the compounding part. Improvements to hand-made setups are per-machine and they decay. Improvements to a repo are per-project and they accumulate. Several orgs on, the container is better than the one I built, and I haven't sat down to work on it since. The fixes came out of ordinary work and got committed like anything else.

The cost of not doing it is easy to underestimate too, because it's paid by the person least able to argue for the fix: the new person, the person under deadline, the person who just wants to deploy the thing. Set the tooling up when you're none of those, and you won't be any of those as often.

Starting the next org from the finish line

The container grew inside one client's repo across five or six pull requests. The last step was pulling it out into a template repository with nothing org-specific left in it: the .devcontainer/ folder, the VS Code task, the lint and prettier config every Salesforce project ends up with anyway, an empty force-app, and the docs. A DEVCONTAINER.md explaining the login mechanics, a DAILY.md for the routine, a skeleton deploy workbook, and a CLAUDE.md so an AI assistant opening the repo knows not to "clean up" the NODE_OPTIONS line.

A new org is now this:

gh repo create acme/salesforce-acme --template <your-account>/sf-org-template --private --clone
cd salesforce-acme

# name the project (two files), then name the org (one gitignored file)
cp .devcontainer/.env.example .devcontainer/.env
$EDITOR .devcontainer/.env          # DEV_ORG_ALIAS, DEV_ORG_INSTANCE_URL

code .                              # → Reopen in Container → log in when the browser opens

Five minutes, most of it the image build, and the terminal says Salesforce org authorized: acme-dev. Every lesson from the last project is already in the files, including the fourteen-line comment about IPv6.

Save point

If you take one thing from this: next time you fix something annoying in your local setup, ask whether the fix could live in the repo instead of on your machine.

If it can, put it there, with the comment that explains why. That's the whole trick. The container is just the place I found to keep it.

One field note a week, by email.

Real engagements, written up and anonymized. Unsubscribe anytime.