Type something to search...
How to set environment variables in Astro?

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 .env and .env.* to .gitignore, and commit a .env.example with placeholder values instead, so teammates know which variables to set.
  • The PUBLIC_ prefix decides visibility. Only variables starting with PUBLIC_ 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:

FileLoaded when
.envAlways
.env.localAlways, meant for local overrides that you keep out of Git
.env.[mode]Only in that mode, for example .env.production
.env.[mode].localOnly 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=changed didn't change what import.meta.env.DB_PASSWORD returned; it was still foobar.
  • Treat dist/server/ as sensitive. Any server-only value read through import.meta.env is 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: development for astro dev, production for astro build, or whatever you pass with --mode.
  • PROD: true in production builds.
  • DEV: true in development; always the opposite of PROD.
  • BASE_URL: the base path your site is served from, from the base config option.
  • SITE: the site value from your Astro config.
  • SSR: true when 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 .env files 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:

  1. No PUBLIC_ prefix on secrets. It's the only thing standing between a variable and your client bundle.
  2. Keep .env files out of Git and out of Docker images, with .gitignore and .dockerignore.
  3. Prefer runtime secrets for credentials. import.meta.env inlines server-only values into dist/server/; process.env or astro:env secrets read them when the server runs instead.
  4. Set production values on the host, not in committed files, and scope them per environment where your host supports it.
  5. 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.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper