.env.example.loor
The committed env template Loor resolves into a real .env. Plain dotenv lines plus {{…}} placeholders that fill in service URLs, managed-database connections, random secrets, and user-supplied values — the same file drives both the dev workspace and production.
What it is
.env.example.loor is a dotenv file you commit to git. It declares the
env vars your app needs — but instead of hardcoding values, you write
{{…}} placeholders that Loor resolves at the right moment. From it, Loor generates
the real .env (which is never committed).
The same template is used in two places, and the field set is identical in both — only the resolved values differ:
- Dev workspace — when your Studio workspace boots, the scaffolder finds every
.env.example.loorin the repo, resolves the placeholders, and writes one merged.envat the project root (mode600). Per-app.envfiles are removed — the root one is authoritative. - Production deploy — at deploy time Loor reads the template at your deploy branch, resolves it against the live deploy context (real domains, managed addons, your saved secrets), and hands the resulting env map to the running containers.
A full example
# .env.example.loor — committed to git (safe: no real values, only shapes)
# Studio resolves every {{…}} placeholder. The real .env it generates is
# never committed.
# Plain literal — copied through verbatim
NODE_ENV=production
# Empty value — you fill it in Studio; deploy blocks until it's set
STRIPE_SECRET_KEY=
# Generated once, then stable across every re-deploy
JWT_SECRET={{RANDOM:48}}
SESSION_SECRET={{RANDOM:32}}
# Wired from another service in this same project
API_URL={{SERVICE:api:url}}
API_PORT={{SERVICE:api:port}}
PUBLIC_WEB_DOMAIN={{SERVICE:web:domain}}
# Managed database / cache connection
MONGODB_URI={{RESOURCE:mongodb:uri}}/myapp
REDIS_URL={{RESOURCE:redis:uri}}
# A value you set in Studio, referenced under a different key name
OPENAI_API_KEY={{USER:OPENAI_KEY}} Line rules
The parser is plain dotenv with a few rules:
- Each line is
KEY=value.KEYmust match[A-Z_][A-Z0-9_]*(uppercase letters, digits, underscore; not starting with a digit). - Blank lines and lines starting with
#are ignored. - Surrounding single or double quotes around a value are stripped.
- A value can mix literal text and placeholders, and hold more than one:
MONGODB_URI={{RESOURCE:mongodb:uri}}/myapp. - An unknown placeholder is left untouched in dev and passed through as the literal
{{X}}in production (and logged) — so a typo is visible, not silently blank.
Placeholder reference
At a glance — the value forms you can write:
VALUE MEANS
NODE_ENV=production literal — copied as-is, not a secret
STRIPE_SECRET_KEY= (empty) required — you supply it in Studio
JWT_SECRET={{RANDOM:48}} 48-char hex, generated once, stays stable
API_URL={{SERVICE:api:url}} another service's url · domain · port
OPENAI_API_KEY={{USER:OPENAI_API_KEY}} a value you set in Studio (alias: {{ENV:KEY}})
MONGODB_URI={{RESOURCE:mongodb:uri}} managed db / cache connection string
R2_BUCKET={{RESOURCE:r2:bucket}} object storage (also auto-injected as R2_*) Literal & required values
A value with no placeholder is copied through verbatim and treated as non-secret
(NODE_ENV=production). An empty value (STRIPE_SECRET_KEY=)
means "the user must supply this": in production the key shows as missing and the deploy
won't proceed until you set it in the Studio env-vars panel; in the dev workspace it's written out
empty for you to fill.
{{RANDOM:n}}
A cryptographically random hex string of length n. Use it for session secrets, JWT
keys, signing salts — anything that just needs to be unguessable and never typed by a human.
JWT_SECRET={{RANDOM:48}} # 48 hex chars
SESSION_SECRET={{RANDOM:32}} # 32 hex chars (always pass a length) Always pass a length. In production a bare {{RANDOM}} defaults to
32, but in the dev workspace the length is required — write {{RANDOM:32}}. In
production the generated value is persisted and reused on every later deploy, so
sessions and tokens survive re-deploys; in dev a fresh value is generated each time the workspace
scaffolds. Random values are always marked secret.
{{SERVICE:name:property}}
Pulls a sibling service's address so you don't hardcode URLs between your own apps. name
is a service key from loor.json; property is
one of:
url—https://<domain>domain— the bare hostnameport— the service's port
# {{SERVICE:<name>:<property>}} — <name> is a service key from loor.json
API_URL={{SERVICE:api:url}} # https://<api's domain>
API_DOMAIN={{SERVICE:api:domain}} # the bare hostname
API_PORT={{SERVICE:api:port}} # the service's port
# In production only, "*" means the primary deploy service
SELF_URL={{SERVICE:*:url}}
In production you can use * as the name to mean the primary deploy service. If the
referenced service has no domain/port yet, the key resolves to empty in dev and is reported as
missing in production.
{{USER:KEY}} / {{ENV:KEY}}
A value you provide — API keys, third-party tokens, anything Loor can't generate or
derive. KEY is the name to look it up under (defaults to the line's own key if you omit
it). Set the value in the Studio env-vars panel; mark it secret there and it's stored encrypted at
rest and masked in the UI.
# Same key name on both sides
OPENAI_API_KEY={{USER:OPENAI_API_KEY}}
# Reference a Studio var stored under a different name
ANALYTICS_KEY={{USER:SEGMENT_WRITE_KEY}}
# {{ENV:KEY}} is an alias for {{USER:KEY}} in production
# (but means "pod env var" in the dev workspace — see Dev vs production) Caveat: {{ENV:KEY}} behaves differently per environment. In
production it's an alias for {{USER:KEY}} (a value you saved in Studio). In the dev
workspace it reads a literal pod environment variable, and {{USER:…}} is
not resolved at all (it falls through to empty). For a value the user supplies in
both dev and prod, prefer an empty KEY= line — see
Dev vs production.
{{RESOURCE:type:property}}
A connection to a Loor-managed backing service. Two kinds:
Databases & cache — type is mongodb or
redis, property uri (or url). You get the base URI
with the path and query stripped, so you append your own database name cleanly. In the dev
workspace this resolves automatically from the pod's MONGODB_URI /
REDIS_URL (whatever the workspace addon provisioned); in production Loor does not
auto-provision these addons yet, so you supply the connection string once in the Studio env-vars
panel under the same key.
Object storage — type is r2, with properties
bucket, endpoint, access_key, secret_key,
region, and public_url (public buckets only). Unlike the databases, the R2
bucket is auto-provisioned and global — the same bucket works in dev and
production — so these resolve automatically in both. You usually don't even need the placeholder:
Loor already injects the standard R2_* vars (see below). Use
{{RESOURCE:r2:…}} only when you want a different env var name.
# Databases / cache — {{RESOURCE:mongodb|redis:uri}}
# Returns the base connection string with no path/query, so you append your own
MONGODB_URI={{RESOURCE:mongodb:uri}}/myapp # …host:27017/myapp
REDIS_URL={{RESOURCE:redis:uri}}
# Object storage — {{RESOURCE:r2:<prop>}}
# bucket · endpoint · access_key · secret_key · region · public_url
R2_BUCKET={{RESOURCE:r2:bucket}}
R2_ENDPOINT={{RESOURCE:r2:endpoint}}
R2_ACCESS_KEY_ID={{RESOURCE:r2:access_key}}
R2_SECRET_ACCESS_KEY={{RESOURCE:r2:secret_key}}
Toggle databases on under resources in
loor.json. R2 is on by default — see Storage (R2) for the
full list of injected variables.
R2 is auto-injected
Because every project gets a bucket automatically, Loor injects these into your app's environment with no template needed — connect with any S3-compatible SDK:
R2_BUCKET # the bucket name
R2_ENDPOINT # https://<account>.r2.cloudflarestorage.com
R2_ACCESS_KEY_ID # app-scoped, object read/write
R2_SECRET_ACCESS_KEY # app-scoped, object read/write
R2_REGION=auto
R2_PUBLIC_URL # public buckets only The access keys are a dedicated app-scoped token (not your account root), and the values are shown in Studio under Project settings → R2 Storage.
Dev vs production
One template, two resolvers. The differences worth knowing:
- RANDOM — dev regenerates each scaffold; production generates once and reuses it on every future deploy.
- RESOURCE — dev reads the workspace addon's pod env automatically; production needs you to paste the connection string once.
- USER / ENV — production resolves both from your saved Studio vars. Dev resolves
{{ENV:KEY}}from the pod's real environment and does not handle{{USER:…}}at all. - Output — dev writes one merged root
.env; production injects the env map straight into the deployed containers.
Studio env-vars you set always layer on top of the template in production: they override a resolved key, or add keys the template never mentioned.
Multiple files (monorepo)
Loor scans the whole repo and merges every file named
.env.example.loor — so a monorepo can keep a template next to each app
(apps/api/.env.example.loor, apps/web/.env.example.loor) with no root-level
aggregator. Each file's contents are prefixed with a # --- <path> --- section
header in the merged output, and when two files set the same key the later section wins (standard
dotenv layering).
Secrets & git safety
.env.example.loor is meant to be committed — that's the point. Keep it that way: it
should only ever contain shapes (literals, placeholders, empty required keys), never a real
secret value. Real values live in two places: {{RANDOM}} values Loor generates, and
everything else in the Studio env-vars panel, encrypted at rest. The generated .env is
written with 600 permissions and should stay out of git (it already matches the default
.gitignore). If you need to edit the live .env in the workspace, Studio
keeps it visible and editable even though it's gitignored.