Astro Environment Variables
Core Patterns
- Public vs Private: Use
PUBLIC_prefix for client-safe variables; omit it for server-only secrets - Environment Files: Layer
.env,.env.local,.env.development, and.env.productionfor environment-specific configuration - TypeScript Support: Declare variables in
ImportMetaEnvinterface for autocomplete and type checking - Validation: Validate all required variables on startup using Zod or custom helpers to fail early
Managing secrets, API keys, and configuration across environments
When to Read This
- Storing API keys and secrets securely
- Different configs for dev/staging/prod
- Accessing environment variables in Astro
- Using
.envfiles properly - Exposing variables to client-side code
Basic Setup
# .env (root directory)
# Private (server-only)
DATABASE_URL=postgresql://localhost/mydb
API_SECRET_KEY=super-secret-key
# Public (available in browser) - MUST have PUBLIC_ prefix
PUBLIC_API_URL=https://api.example.com
PUBLIC_SITE_NAME=My Astro Site
---
// Server-side: Access any variable
const dbUrl = import.meta.env.DATABASE_URL;
const apiKey = import.meta.env.API_SECRET_KEY;
// Client-side: Only PUBLIC_ variables
const apiUrl = import.meta.env.PUBLIC_API_URL;
---
<script>
// ✅ CORRECT: PUBLIC_ variables available
const apiUrl = import.meta.env.PUBLIC_API_URL;
// ❌ WRONG: Private variables are undefined in browser
const secret = import.meta.env.API_SECRET_KEY; // undefined!
</script>
Environment-Specific Files
.env # Loaded in all environments
.env.local # Local overrides (gitignored)
.env.development # Only in dev mode
.env.production # Only in production build
Load priority:
.env.productionor.env.development(environment-specific).env.local(local overrides).env(defaults)
# .env (committed, defaults)
PUBLIC_API_URL=https://api-staging.example.com
DATABASE_URL=
# .env.local (gitignored, local dev)
DATABASE_URL=postgresql://localhost/mydb_dev
API_SECRET_KEY=dev-secret
# .env.production (committed, production)
PUBLIC_API_URL=https://api.example.com
TypeScript Support
// src/env.d.ts
/// <reference types="astro/client" />
interface ImportMetaEnv {
readonly DATABASE_URL: string;
readonly API_SECRET_KEY: string;
readonly PUBLIC_API_URL: string;
readonly PUBLIC_SITE_NAME: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
TypeScript autocomplete and type checking for env variables.
Validation
// src/lib/env.ts
function getEnv(key: keyof ImportMetaEnv): string {
const value = import.meta.env[key];
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
export const config = {
databaseUrl: getEnv("DATABASE_URL"),
apiKey: getEnv("API_SECRET_KEY"),
publicApiUrl: getEnv("PUBLIC_API_URL"),
};
Zod Validation
// src/lib/env.ts
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().url(),
API_SECRET_KEY: z.string().min(20),
PUBLIC_API_URL: z.string().url(),
PUBLIC_SITE_NAME: z.string().default("My Site"),
});
export const env = envSchema.parse(import.meta.env);
Security
⚠️ CRITICAL: Never Expose Secrets
---
// ❌ WRONG: Exposing secret to client
const apiKey = import.meta.env.API_SECRET_KEY;
---
<script define:vars={{ apiKey }}>
// Secret is now in browser! Security breach!
console.log(apiKey);
</script>
---
// ✅ CORRECT: Keep secrets server-side
const apiKey = import.meta.env.API_SECRET_KEY;
const data = await fetchWithAuth(apiKey);
---
<!-- Only send safe data to client -->
<div>{data.publicInfo}</div>
# ✅ CORRECT: Safe for browser
PUBLIC_GOOGLE_ANALYTICS_ID=UA-123456
PUBLIC_STRIPE_PUBLIC_KEY=pk_test_123
# ❌ WRONG: Secrets without PUBLIC_ prefix (but could be misused)
STRIPE_SECRET_KEY=sk_live_123 # Keep server-side only!
Common Patterns
API Endpoints with Secrets
// src/pages/api/data.ts
export async function GET() {
const apiKey = import.meta.env.API_SECRET_KEY;
const response = await fetch("https://api.example.com/data", {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { "Content-Type": "application/json" },
});
}
Database Connections
// src/lib/db.ts
import { Pool } from "pg";
export const pool = new Pool({
connectionString: import.meta.env.DATABASE_URL,
ssl: import.meta.env.PROD ? { rejectUnauthorized: false } : false,
});
Feature Flags
---
const enableBetaFeatures = import.meta.env.PUBLIC_ENABLE_BETA === 'true';
---
{enableBetaFeatures && (
<div>Beta feature enabled!</div>
)}
Deployment
Vercel
# Vercel automatically loads .env.production
# Or set via Vercel dashboard: Settings → Environment Variables
Netlify
# Netlify UI: Site settings → Environment variables
# Or netlify.toml:
[build.environment]
PUBLIC_API_URL = "https://api.example.com"
Cloudflare Pages
# Cloudflare dashboard: Settings → Environment variables
# Or wrangler.toml:
[env.production.vars]
PUBLIC_API_URL = "https://api.example.com"
Best Practices
- Never commit
.env.local— add to.gitignore - Use
PUBLIC_prefix only for client-safe values - Validate env variables on startup (Zod or custom)
- Document required variables in README.md
- Use environment-specific files (
.env.production,.env.development) - Rotate secrets regularly, never hardcode them
Edge Cases
Undefined variables: import.meta.env.MISSING_VAR returns undefined, not an error. Validate early.
Build-time vs Runtime: Variables are replaced at build time for static pages. SSR pages access them at runtime.
Empty strings: .env empty values (VAR=) result in "", not undefined.
Multiline values: Not supported in .env. Use base64 or JSON strings for complex values.