A launch profile decides where a service's environment variables come from
when Localify starts it: local dotenv files, Infisical, Proton Pass, or a mix. The service,
its .localify domain and its port stay the same. Only the variables change.
# From the monorepo root: pick the app in the selector, and it starts.
localify dev --profile prod
localify dev -p prod # same, with the short flagThese are two different things, with two different flags:
--env <name> |
--profile <name> |
|
|---|---|---|
| What changes | The checkout: a Git worktree or branch | The variables the service starts with |
| Domain and port | Its own (feat-auth.my-app.localify) |
Unchanged |
| Declared in | Localify config (localify project env ensure) |
localify.toml in the checkout, or implicit with Infisical |
You can combine them: localify dev web --env feat-auth --profile staging.
Run it from the project folder:
localify profile initIt walks you through the setup:
- Questions: what services start with when
--profileis omitted, then one profile at a time (name, sources, dangerous or not). - Infisical: offered first when the project has
.infisical.yaml. It asks for the environment slug and the folder of each service. - Proton Pass: it lists your vaults and suggests the note title pattern your existing notes
already follow, such as
Acme/{service_path}/.env.production. - Checks: it marks each service ✓ or ✗ depending on whether its file, note or Infisical folder exists. It reads note titles and folder names only, never values.
- Writing: it shows the file, and writes it only after you confirm. Rewriting an existing file drops its comments.
It needs a terminal. Without one, write the file by hand as described below.
Profiles live in localify.toml at the root of the project checkout. The file
only holds addresses (file paths, vault names, note titles), never values, so
commit it. Every worktree gets the same profiles.
# localify.toml at the root of the monorepo
[profiles.default]
# Used when --profile is omitted. It can never be dangerous.
sources = [
{ file = ".env.development" },
]
[profiles.prod]
danger = true
# max_duration = "4h" # optional: stop the service automatically
# browser_indicator = false # optional: hide the in-page indicator
sources = [
{ proton_pass_note = { vault = "Env", title = "Acme/{service_path}/.env.production" } },
]
[profiles.staging]
sources = [
{ file = ".env.staging.refs" }, # values such as pass://Staging/acme-db/password
{ file = ".env.development" }, # fills in the keys the first file does not define
]Rules:
- Order: sources are read in order, and the first source that defines a key wins. Inside one file, the last duplicate wins, as with dotenv.
file: the path is relative to the service folder (apps/web/), not to the folder you ran the command from.infisical: reads every secret of one Infisical folder; see Infisical.proton_pass_note: reads a Proton Pass note whose body is a complete dotenv file.vaultandtitleare names.{service_path}expands to the service folder relative to the project root, and{service_name}to its last segment (webforapps/web), so one profile covers every app of a monorepo. Forapps/web, the example above readsAcme/apps/web/.env.production. For a service at the root,{service_path}/disappears and the title becomesAcme/.env.production.pass://references: any value that starts withpass://, in afilesource or in--env-file, is resolved through Proton Pass at launch. Names work (pass://Vault/Item/field); a title that contains/must be encoded as%2F.danger: turns on the guardrails described below.max_duration: optional; accepts durations such as"4h"or"90m". Without it, the service runs until you stop it.browser_indicator: shows the profile in the app's pages (see below). On by default for dangerous profiles, off otherwise; set it to change that.- Typos: an unknown key, such as
dangr = true, is an error, so a typo cannot silently turn a guardrail off. - No profile: without
localify.toml, without.infisical.yamland without--profile, services launch exactly as before.
--env-file <path> adds a one-off dotenv file, before the profile's sources. It can be repeated,
its path is relative to the current directory, and a missing file is always an error.
Running locally on production data should not send real emails, push notifications or invoices. Most of these side effects are driven by an environment variable, so a profile can force safe values over its sources:
[profiles.prod.env]
CRONS_ENABLED = "false" # a switch your app reads
INFAST_DRY_RUN = true # a dry-run flag the integration already has
RESEND_TOKEN = "re_disabled" # keeps the "re_" format validators expect; sending fails
SMTP_HOST = "smtp.disabled.invalid" # .invalid never resolves- Priority: these values win over every source and over the framework's own files. Only the shell and Localify's runtime variables win over them.
- Values: strings, booleans and numbers.
- Neutral values: pick values that keep the app booting (env validators often check a format) but make the side effect fail or no-op.
- No switch in the app: when a side effect has no switch, such as a cron that always runs, add one
(
CRONS_ENABLED) and set it here. CheckingLOCALIFY_DANGER=1also works, but a named switch keeps the app independent of Localify. - Setup: for a dangerous profile,
localify profile initlists the keys that look like side effects (dry-run and*_ENABLEDswitches, crons, email, SMS and push providers), by name only, and writes the ones you pick.
A database studio, a migration or a worker with its own port is not a Localify service. Run it with the variables of a profile from its folder:
cd packages/database && localify exec --profile prod -- drizzle-kit studio --port 3005- Which
localify.toml: the nearest one in the current folder or its parents, or the nearest.infisical.yamlfor implicit profiles. - Sources: they are read relative to the current folder, which also becomes
{service_path}(packages/database). - Variables: Localify injects no
PORT,HOSTor URL variables, only the profile markers (LOCALIFY_LAUNCH_PROFILE,LOCALIFY_DANGER). The shell still wins over the profile. - Same rules as
dev: masking,--env-file,--confirm-profilewithout a terminal, and for a dangerous profile the red banner, the terminal title and the audit log (so the daemon must run). - Exit code: it is the command's own, so
localify execworks inpackage.jsonscripts, for example"db:studio:prod": "localify exec --profile prod -- drizzle-kit studio".
From strongest to weakest:
- Localify runtime variables, which keep routing working:
PORT,HOST,LOCALIFY_URL, the URL variables,NODE_EXTRA_CA_CERTS,LOCALIFY_LAUNCH_PROFILEandLOCALIFY_DANGER. URL keys found in the profile, such asNEXT_PUBLIC_APP_URLorAUTH_URL, are rewritten to the local domain, so a production profile cannot send OAuth callbacks to production.BETTER_AUTH_TRUSTED_ORIGINSfrom the profile or the shell is kept, with your public URLs' origins appended (README). - Variables already exported in your shell. Localify prints the names of the profile keys your shell overrides.
[profiles.<name>.env], then--env-file, then the profile's sources.- Files the framework loads itself (
.env.development,.env.local…). Next.js, Vite and dotenv never override a variable already present in the process.
One side effect of rule 4: an injected variable also wins over .env.local. Prefer listing files the
framework does not load on its own.
The service sees LOCALIFY_LAUNCH_PROFILE=<name>, plus LOCALIFY_DANGER=1 for a dangerous profile.
Use them to render the app's own banner or block risky actions.
- Normal profile: the service starts, and Localify prints which file, note, reference or Infisical
folder is missing. For Infisical, this includes an expired session or a missing CLI, so a plain
localify devkeeps working. - Dangerous profile: the launch fails. Starting on a mix of development and production values is worse than not starting.
- No prompt in a terminal:
--profile prodis already explicit, and the indicators below follow. Without a terminal (agents, scripts, CI),--confirm-profile <name>is required and must repeat the name, so automation cannot start a dangerous profile by accident. - Terminal: a red banner prints at startup, and the terminal title becomes
[PROD] web. - Desktop app: the service row shows a red badge with the profile name while it runs.
- Browser: the tab title gets a
[PROD]prefix, and a small red pill with the profile name sits on top of the page. Drag it anywhere; the position is remembered per site. - Browser tools and API clients: the proxy adds
X-Localify-Profile: <name>to the service's responses. - No default:
defaultcannot be dangerous, and the desktop app never remembers a chosen profile, so a relaunch the user did not choose usesdefault. - Time limit: with
max_duration, Localify stops the service and its child processes when the time is up, and says so. - Daemon required: a dangerous launch needs the Localify daemon, which records it. If the daemon cannot be reached, the service does not start.
- Audit log: every start and stop goes to the daemon log (
localify logs), with service, profile, sources with key counts, timestamps and stop reason. Values are never logged.
If the project is linked to Infisical, you have nothing to write. Run this once at the project root:
infisical init # writes .infisical.yaml; commit itWithout localify.toml, Localify then offers three profiles that read the Infisical environment of
the same name:
| Command | Infisical environment | Dangerous |
|---|---|---|
localify dev |
dev |
no |
localify dev --profile staging |
staging |
no |
localify dev --profile prod |
prod |
yes |
Each service reads the folder named after its own folder: apps/web reads /web, and a service at
the root reads /. The desktop app's launch menu shows the same three profiles.
To change any of this (another environment, another folder, a mix with local files, forced values
for prod), declare the profiles in localify.toml, which then replaces the implicit ones:
[profiles.default]
sources = [
{ infisical = {} }, # environment dev, folder /{service_name}
{ file = ".env.development" }, # fills in the keys Infisical does not define
]
[profiles.prod]
danger = true
sources = [
{ infisical = { env = "production", path = "/{service_path}" } },
]env: the environment slug, as shown in the project settings on Infisical. Without it,defaultreadsdevand other profiles read the environment of their own name.path: the folder, starting with/. Defaults to/{service_name}. Secrets imported into that folder in Infisical are included.- CLI: Localify calls the official
infisicalCLI with your login, from the project root. The instance (cloud, EU or self-hosted) and the project come from the CLI's own configuration. Values stay in memory and are masked in the output, like Proton Pass values. - Several organizations: when the CLI's current login profile belongs to another organization
than the project, Localify tries your other login profiles (
infisical profile list) and shows the one that worked next to the source.infisical profile bind <name>at the project root skips that retry. - Personal overrides: your personal secrets override shared ones, as in
infisical run. - Unreadable values: a key whose value your account may not read is not injected, and counts as a missing source: a dangerous profile refuses to start.
- Time limit: a CLI call that takes more than 20 seconds (typically an expired session waiting for a browser login) is stopped, and the source counts as missing.
brew install infisical/get-cli/infisical
infisical loginLocalify calls the official CLI, pass-cli, and keeps values in memory. It writes no temporary file
and keeps no cache.
brew install protonpass/tap/pass-cli
pass-cli loginIf pass-cli is missing or signed out, the error gives the command to run.
Masking: when a value comes from Proton Pass or Infisical, Localify masks it in the service's output as ***.
- Values shorter than 8 characters are not masked.
NEXT_PUBLIC_*,VITE_*,PUBLIC_*andEXPO_PUBLIC_*values are not masked, since they ship to the browser anyway.- Masking is best effort: a value the app encodes or splits is not recognized.
- The service's stdout and stderr go through a pipe. Localify sets
FORCE_COLOR=1when you did not, and stdin stays your terminal, so dev-server shortcuts keep working. --no-maskingturns masking off.
Why not pass-cli run: pass-cli run --env-file overwrites variables already exported in the
shell, which breaks rules 1 and 2 above. It also cannot read a note as a whole dotenv file, and it
detaches the service from the terminal.
If your notes are named <project>/<path of the file> in one vault (for example
Acme/apps/web/.env.production in Env), point a profile at them with
title = "Acme/{service_path}/.env.production". You do not need to rename anything.
- Launching: the launch button uses
default. When the checkout has other profiles (declared, or implicit for a project linked to Infisical), a small chevron next to it offers "Launch with ". Dangerous profiles are red and show their time limit. - Terminal: the launch opens the integrated terminal and gives it focus.
- Badges: running services show a badge with their profile name.
defaultgets none. - Broken file: a
localify.tomlthat cannot be read shows a warning instead of the chevron.
The desktop app runs the same localify dev --profile <name> command as the CLI and duplicates none
of its logic.
For a profile with browser_indicator on, the proxy adds one tag right after <head> in page
navigations (not in fetches, assets, iframes or HMR):
<script src="/.localify/launch-profile.js" data-profile="prod" defer></script>The script is served by Localify on the app's own origin. After the page's load event, once the
framework has hydrated, it prefixes the tab title and adds the pill. It keeps the prefix when the app
changes the title during client-side navigation, and puts the pill back if the app re-renders <body>.
How it stays out of the app's way:
- CSP: the tag is a same-origin script, allowed by
script-src 'self'. When the page's CSP header uses a nonce, the tag reuses it. The pill lives in a shadow root and is styled through the CSSOM, whichstyle-srcdoes not restrict. Tested withstyle-src 'self'on Next.js 16. - Hydration: nothing touches the DOM before
load. React 19 also skips the extra<script>in<head>during hydration. Tested on Next.js 16 and 14 with client navigation. - Streaming: only the start of the document is scanned; the rest of the stream passes through. To edit the page, the proxy asks the dev server for an uncompressed response.
Limits: a CSP that allows neither 'self' scripts nor a header nonce blocks the script (the app keeps
working, without the indicator). Apps that hydrate the whole document with React 18 may report a
hydration warning for the extra tag. Turn it off with browser_indicator = false; apps can still
read LOCALIFY_DANGER=1 and render their own banner.