
How to set environment variables in Astro?
To set environment variables in Astro, put them in a .env file in your project root, or set them in your shell or hosting dashboard, and read them with import.meta.env.NAME. Variables whose names start with PUBLIC_ are available in both server and browser code; all other variables are server-only. Add .env.production, .env.staging or other mode files to change values per environment, and choose the mode with astro build --mode staging. Since Astro 6, import.meta.env values are always replaced in your code at build time and kept as strings. For values that must be read when the server runs, use process.env in on-demand routes, or better, Astro's typed astro:env API.
Environment variables look simple, but Astro reads them at different moments depending on where your code runs, and that causes most of the bugs people hit: a value that's missing in production, a secret that ended up in a bundle, or a "false" that behaves like true. In this article you'll learn every way to set variables, how import.meta.env and process.env differ, which variables Astro provides by default, how modes work, how to use variables in astro.config.mjs, how to add TypeScript types, and how to keep secrets safe.
Setting Variables With .env Files
Create a .env file in the project root:
# .env
DB_PASSWORD="foobar"
FEATURE_ENABLED=true
PUBLIC_API_URL="https://api.example.com"
Astro loads it automatically when you run astro dev or astro build. Each line is NAME=value, comments start with #, and quotes are optional unless the value contains spaces or special characters.
Two rules matter straight away:
- Never commit real secrets. Add
.envand.env.*to.gitignore, and commit a.env.examplewith placeholder values instead, so teammates know which variables to set. - The
PUBLIC_prefix decides visibility. Only variables starting withPUBLIC_are exposed to client-side code. Anything else is available on the server only.
Mode-Specific Files
You can have different files for different situations:
| File | Loaded when |
|---|---|
.env | Always |
.env.local | Always, meant for local overrides that you keep out of Git |
.env.[mode] | Only in that mode, for example .env.production |
.env.[mode].local | Only in that mode, local overrides |
astro dev runs in development mode and astro build runs in production mode. To use another mode, pass --mode:
npx astro build --mode staging
With a .env.staging file containing a different API URL, the same page rendered:
<p>API: https://staging-api.example.com</p>
<p>Mode: staging, PROD: true</p>
Values in the mode file override the same names in .env. Note that PROD is still true: a custom mode used with astro build is still a production build. The file loading is Vite's, so the Vite env and mode documentation is the reference for precedence rules.
Setting Variables From the Command Line
You can also set variables directly when you run a command:
PUBLIC_API_URL=https://api.example.com npm run build
Variables already in the environment take priority over values in .env files. This is how CI systems and hosting platforms provide values: you set them in the Vercel, Netlify or Cloudflare dashboard, or in your CI's secret settings, and they're present in the environment during the build.
Reading Variables With import.meta.env
In Astro components, pages, endpoints, middleware and client scripts, read variables from import.meta.env:
---
// src/pages/env-demo.astro
export const prerender = false;
const apiUrl = import.meta.env.PUBLIC_API_URL;
const hasPassword = Boolean(import.meta.env.DB_PASSWORD);
const enabled = import.meta.env.FEATURE_ENABLED;
const runtimeValue = process.env.RUNTIME_ONLY ?? 'not set';
---
<p>API: {apiUrl}</p>
<p>Password set: {String(hasPassword)}</p>
<p>FEATURE_ENABLED is a {typeof enabled}: {enabled}</p>
<p>Runtime value: {runtimeValue}</p>
<p>Mode: {import.meta.env.MODE}, PROD: {String(import.meta.env.PROD)}</p>
<script>
console.log('client API', import.meta.env.PUBLIC_API_URL);
console.log('client secret', import.meta.env.DB_PASSWORD);
</script>
Built with the Node adapter and started with RUNTIME_ONLY=hello, this page rendered:
<p>API: https://api.example.com</p>
<p>Password set: true</p>
<p>FEATURE_ENABLED is a string: true</p>
<p>Runtime value: hello</p>
<p>Mode: production, PROD: true</p>
And the client script in the built output had become:
console.log(`client API`,`https://api.example.com`),console.log(`client secret`,void 0);
That output shows the three most important behaviours.
Values Are Inlined at Build Time
import.meta.env.X isn't a lookup that happens when the code runs. During the build, Vite replaces each reference with the literal value. In the client script, the public URL became a string and the secret became void 0, which is undefined, so the secret never reached the browser.
On the server it's the same: the value is written into the built code. In the test build, DB_PASSWORD's value foobar appeared as a literal in the page's chunk under dist/server/. Two consequences follow:
- Changing a variable needs a rebuild. Starting the built server with
DB_PASSWORD=changeddidn't change whatimport.meta.env.DB_PASSWORDreturned; it was stillfoobar. - Treat
dist/server/as sensitive. Any server-only value read throughimport.meta.envis inside those files. Don't publish them, and don't copy them into public artefacts.
This has been the default since Astro 6. In Astro 5, non-public variables were turned into a process.env lookup instead, so older articles may describe different behaviour.
Values Are Never Coerced
FEATURE_ENABLED=true arrives as the string "true", not a boolean. Since Astro 6, values are never converted, which avoids surprises but means you compare explicitly:
const enabled = import.meta.env.FEATURE_ENABLED === 'true';
const port = Number(import.meta.env.PORT ?? '4321');
Be careful with checks like if (import.meta.env.FEATURE_ENABLED): the string "false" is truthy, so the feature would be on.
PUBLIC_ Controls What Reaches the Browser
Every variable is available in server code, but only PUBLIC_ variables are replaced in client code. Anything you put behind PUBLIC_ is visible to anyone who opens your JavaScript, so never use the prefix for API keys that aren't meant to be public.
Reading Variables at Runtime With process.env
When you need a value that can change without rebuilding, such as a database password that differs between servers, read it at request time in an on-demand route. With the Node adapter, use process.env:
---
// src/pages/env-check.astro
export const prerender = false;
---
<p>meta: {import.meta.env.DB_PASSWORD}</p>
<p>process: {process.env.DB_PASSWORD ?? 'undefined'}</p>
Built once, then started two ways:
node ./dist/server/entry.mjs
DB_PASSWORD=changed node ./dist/server/entry.mjs
<p>meta: foobar</p>
<p>process: undefined</p>
<p>meta: foobar</p>
<p>process: changed</p>
Notice the first result: process.env is undefined even though .env contains the variable. .env files feed import.meta.env; they don't populate process.env, not even in astro dev, where the same page also showed process: undefined. The Node adapter's docs confirm that neither Astro nor the adapter loads environment variables for you at runtime. Pass them to the process yourself, through your host's settings, docker run --env-file or a tool like dotenvx.
How you read runtime variables depends on the adapter. Most Node-based platforms, including Vercel and Netlify functions, support process.env. On Cloudflare Workers, use import { env } from 'cloudflare:workers'. The portable option is astro:env, whose getSecret() is implemented by each adapter. See deploying to Cloudflare for the Workers specifics.
Prerendered Pages Only See Build-Time Values
A prerendered page runs once during the build. Whatever process.env or import.meta.env returns at that moment is baked into the HTML. If a page must show a value that changes at runtime, it has to be rendered on demand. The rules for that are in mixing prerendered and on-demand pages.
Default Environment Variables
Astro provides these on import.meta.env without any setup:
MODE:developmentforastro dev,productionforastro build, or whatever you pass with--mode.PROD:truein production builds.DEV:truein development; always the opposite ofPROD.BASE_URL: the base path your site is served from, from thebaseconfig option.SITE: thesitevalue from your Astro config.SSR:truewhen the code is running on the server, which helps in modules shared between server and client.
// src/lib/log.ts
export function debug(...args: unknown[]) {
if (import.meta.env.DEV) {
console.log('[debug]', ...args);
}
}
Using Variables in astro.config.mjs
Astro loads your config before it loads .env files, so import.meta.env doesn't contain your .env values in astro.config.mjs. In a test, logging import.meta.env?.PUBLIC_API_URL from the config printed undefined. You have two options:
process.env: works for variables set in the shell, CI or hosting dashboard.- Vite's
loadEnv(): loads.envfiles manually.
// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import { loadEnv } from 'vite';
const { SITE_URL } = loadEnv(process.env.NODE_ENV, process.cwd(), '');
export default defineConfig({
site: SITE_URL,
adapter: node({ mode: 'standalone' }),
});
The empty string as the third argument loads every variable, not just PUBLIC_ ones. vite is a dependency of Astro, so this import works with npm. With pnpm, which doesn't allow importing packages you haven't installed directly, add it with pnpm add -D vite first.
Adding TypeScript Types
import.meta.env is typed through astro/client, but your own variables are unknown to TypeScript until you declare them. Add them in src/env.d.ts:
// src/env.d.ts
interface ImportMetaEnv {
readonly DB_PASSWORD: string;
readonly FEATURE_ENABLED: string;
readonly PUBLIC_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
Declare them as string, because that's what they are at runtime since values are no longer coerced. These types are only a promise; nothing checks that the variables actually exist. If you want real validation, with numbers, booleans, enums and an error when a required variable is missing, use astro:env for type-safe environment variables.
Keeping Secrets Safe
A short checklist:
- No
PUBLIC_prefix on secrets. It's the only thing standing between a variable and your client bundle. - Keep
.envfiles out of Git and out of Docker images, with.gitignoreand.dockerignore. - Prefer runtime secrets for credentials.
import.meta.envinlines server-only values intodist/server/;process.envorastro:envsecrets read them when the server runs instead. - Set production values on the host, not in committed files, and scope them per environment where your host supports it.
- Don't log the whole environment. A
console.log(import.meta.env)in shared code can print values you didn't mean to expose.
FAQ: Environment Variables in Astro
Only variables whose names start with PUBLIC_ are available in client-side code. Server-only variables are replaced with undefined in client bundles. Rename the variable with the PUBLIC_ prefix if it's safe to expose, or fetch the data through an endpoint instead.
Values read through import.meta.env are inlined when you build, so the built code contains the old value. Rebuild and redeploy, or read the value at request time with process.env or an astro:env secret in an on-demand route.
Environment variables are strings, and since Astro 6 Astro never converts them. The string "false" is truthy. Compare explicitly, for example import.meta.env.FEATURE_FLAG === "true", or define it as a boolean with astro:env.
Not through import.meta.env, because the config is loaded before .env files. Use process.env for variables set in the environment, or call loadEnv() from vite to read the .env files yourself.
Only at build time. The values are inlined wherever import.meta.env is used. At runtime, the Node adapter doesn't load .env files into process.env, so pass runtime variables through your host, your process manager or Docker.
import.meta.env is fine for simple, public, build-time values. For anything you want validated, typed or read at runtime, especially secrets, astro:env is the better choice because it checks a schema and lets the adapter supply secrets when the server runs.
Conclusion
Astro gives you several ways to set environment variables, .env files, mode-specific files, the shell and your hosting dashboard, and two main ways to read them. import.meta.env is replaced at build time, keeps values as strings, and exposes only PUBLIC_ variables to the browser. process.env in on-demand routes reads values when the server runs, but only those actually present in the process environment, not your .env files.
Once you know when each one is evaluated, the common bugs disappear. Rebuild when you change build-time values, compare strings explicitly, keep secrets out of PUBLIC_ names and committed files, and use loadEnv() in your config. When you outgrow manual type declarations and want validation, move to astro:env, which builds on everything here with a schema.


