config.yml — Personal Local Config
.foundry/config.yml is gitignored — it holds personal preferences, credential pointers, and per-developer overrides. The nested .foundry/.gitignore excludes it by name, so it is never tracked even with git add ..
Foundry only creates this file when you ask it to (for example, foundry github auth --save). Otherwise create it yourself when you need it.What goes here
- A GitHub token (
github.token) for cross-repo discovery - Per-service SSH tunnel settings used by
foundry db - Personal overrides you don't want to share with the team
Example
# .foundry/config.yml — GITIGNORED
github:
token: ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
services:
api:
sshTunnel:
host: bastion.example.com
user: ec2-user
password: ci/keys/bastion.pem # path to the SSH key
remoteHost: db.internal
remotePort: 5432
localPort: 5432Defaults vs local
A committed config.defaults.yml can hold team-shared defaults (for example, a shared bastion host). The CLI deep-merges config.yml over config.defaults.yml, so your personal file only needs the values that differ.
Dev-run env files and targets
foundry run dev resolves each service's environment before starting it: manifest run.env, then the committed .foundry/dev.env, then the gitignored .foundry/dev.local.env. From that stack it picks a target per service: FOUNDRY_DEV_TARGET=prod starts the manifest's SSH tunnel and injects database credentials pointed at it; local (or nothing configured) skips the tunnel entirely and the service's own local defaults apply.
# .foundry/dev.env — COMMITTED team default
FOUNDRY_DEV_TARGET=prod
# .foundry/dev.local.env — GITIGNORED personal override
FOUNDRY_DEV_TARGET=localThe tunnel itself can autowire from AWS when the manifest opts in — all fields optional:bastionTag resolves the host by EC2 Name tag when the host variable is unset,keySecret fetches the pem from Secrets Manager when the key file is missing, and credentialsSecret/injectEnv inject database credentials into the service process (never written to disk).
Multiple tunnels per service
A service that needs several tunnels at once (an auth service plus two databases, say) declares a named sshTunnels map instead of the singular sshTunnel. All tunnels open in parallel before the service starts — all-or-nothing: if any fails, the rest are closed and the service does not start. Map entries inject only what they declare: an env block (with ${localPort}/${localHost} expansion) plus injectEnv fields read from credentialsSecret. The legacy singular form keeps its implicit DB_HOST/DB_PORT and default credential injection; declaring both forms is an error.
services:
api:
sshTunnels:
authService:
localPort: 18080
remoteHost: auth.internal
remotePort: 443
host: ${BASTION_HOST}
env:
AUTH_BASE_URL: http://localhost:${localPort}
db: # the entry named "db" is what
localPort: 15432 # `foundry db` migrations tunnel through
remoteHost: ${DB1_HOST}
remotePort: 5432
host: ${BASTION_HOST}
credentialsSecret: acme-prod/api/db1
env: { DB1_HOST: localhost, DB1_PORT: "${localPort}" }
injectEnv: { DB1_USER: username, DB1_PASSWORD: password }
replicaDb:
localPort: 15433
remoteHost: ${DB2_HOST}
remotePort: 5432
host: ${BASTION_HOST}
credentialsSecret: acme-prod/api/db2
env: { DB2_PORT: "${localPort}" }
injectEnv: { DB2_USER: username, DB2_PASSWORD: password }Every tunnel needs its own localPort, and two tunnels may not inject the same env var — both are hard errors before anything connects. In config.yml, sshTunnels overrides merge by name: a name mapped to an object replaces that tunnel, a name mapped to null removes it, and sshTunnels: false disables them all.
Bind address — loopback by default
Tunnels listen on loopback only: 127.0.0.1 plus ::1 (so -h localhost works on dual-stack machines where localhost resolves to ::1 first, e.g. macOS). Nothing else on your network can reach the tunnelled service — the same default as ssh -L. If something off-host must connect (a Docker container reaching the tunnel via host.docker.internal), opt in per tunnel with bindAddress:
sshTunnels:
db:
localPort: 15432
remoteHost: ${DB_HOST}
remotePort: 5432
host: ${BASTION_HOST}
bindAddress: 0.0.0.0 # EXPOSES the port to your LAN — containers onlyA non-loopback bindAddress logs a warning at startup — the tunnelled database becomes reachable by every machine on the local network (coffee-shop wifi included). bindAddress is IPv4-only; the ::1 listener exists only on the default loopback bind.
A localPort already held by another process is a hard error (the tunnel fails, and with it the service — all-or-nothing). Foundry never assumes an existing listener is its own tunnel: stop the other process or pick a different port.
SSH authentication — key file or agent
The password field (a path to an SSH private key file) is optional. When omitted, authentication falls back to your SSH agent (and keys in ~/.ssh) — the right choice when your key is passphrase-encrypted: encrypted key files are not supported (there is no passphrase field), but an agent-loaded key works regardless. keySecret can still fetch an unencrypted pem from AWS Secrets Manager into the password path when the file is missing.
Named environments — run several side by side
A service can declare per-environment dev overlays inside its existing environments block. foundry run dev --env staging (or FOUNDRY_DEV_ENV=staging; the flag wins) applies each service's environments.staging overlay: run shallow-merges (port, args, script replace; run.env layers in), env layers in, and sshTunnels merges by tunnel name — an existing tunnel changes only the fields you give, a new name is a full tunnel, null removes one. An environment used only for dev needs no branch; branchless environments are invisible to the CI generators.
services:
api:
run: { port: 8091 }
sshTunnels:
db: { localPort: 15432, remoteHost: ${DB_HOST}, remotePort: 5432,
host: ${BASTION_HOST}, credentialsSecret: acme-prod/api/db }
environments:
staging: # dev-only: no branch
run: { port: 9091 }
sshTunnels:
db: { localPort: 25432, credentialsSecret: acme-staging/api/db }Env files gain a per-environment pair layered between the base ones: dev.env → dev.staging.env → dev.local.env → dev.staging.local.env (the *.local.env pattern stays gitignored). Give each environment distinct ports (service port and tunnel localPorts) and two terminals can run foundry run dev and foundry run dev --env staging simultaneously — a port collision fails loudly at bind, and the fix is one overlay line.
Multi-repo workspaces and profiles
A platform's ops repo can carry a master foundry.workspace.json at its root naming the member repos (sibling clones) and named run profiles. Its schema is published at https://raw.githubusercontent.com/FoundryMedia/foundry/release/foundry.workspace.schema.json — reference it from $schema for editor validation:
{
"$schema": "https://raw.githubusercontent.com/FoundryMedia/foundry/release/foundry.workspace.schema.json",
"repos": ["fid", "foundry-auth-efga", "foundry-app"],
"profiles": {
"core": ["fid", "auth-efga"],
"web": ["fid", "auth-efga", "web"]
}
}repos are member repository directory names, each with its own .foundry/foundry.json; profiles map a name to manifest service names (the keys under services, not repo names). foundry run dev:core (shorthand for dev --profile core) boots that profile's services from every member repo in one session — each service keeps its own repo's env files, tunnels, and credentials. Running foundry run dev from a directory with no manifest falls back to the full workspace (everything discoverable). Repos not cloned locally are skipped with a notice. FOUNDRY_WORKSPACE points at a specific workspace file when discovery shouldn't walk the filesystem. --filter a,b,c remains the ad-hoc alternative for a one-off subset; profiles are the shareable, committed form.
GitHub token shortcut
foundry github auth --token ghp_... --saveThis writes the token into config.yml for you. Alternatively use the GITHUB_TOKEN environment variable or the GitHub CLI (gh auth login).