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: 5432

Defaults 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=local

The 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 only

A 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.envdev.staging.envdev.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_... --save

This writes the token into config.yml for you. Alternatively use the GITHUB_TOKEN environment variable or the GitHub CLI (gh auth login).