Environment Variables
Where configuration and secrets come from, which ones reach the browser, and how to keep the rest on the server.
Every variable is server-only unless its name starts with GIO_PUBLIC_. Server code - getServerSideProps, page actions, route.ts handlers, anything they import - reads process.env as usual. Code that also runs in the browser (pages, layouts, components) sees only the GIO_PUBLIC_* values, inlined when the client bundles are built. This page walks through the whole flow; the configuration reference lists every variable GioJS itself reads.
Where values come from
At startup the Rust server reads .env files from the project root (the folder holding app/ and gio.toml), before it parses gio.toml and before the Node worker starts, so both halves see the same values. For each variable the first source that defines it wins:
| Precedence | Source | Commit it? |
|---|---|---|
| 1 | The real environment (shell, systemd, Docker, your host's dashboard) | - |
| 2 | .env.{mode}.local | no - machine-specific values and secrets |
| 3 | .env.local | no - machine-specific values and secrets |
| 4 | .env.{mode} | yes - per-mode defaults |
| 5 | .env | yes - shared defaults |
{mode}isdevelopmentwhen the server starts withNODE_ENV=development(npm run dev) andproductionotherwise - an unsetNODE_ENVincluded.NODE_ENVitself is never read from a file: set it in the real environment.- Real environment variables always win, so a value your deploy sets is never shadowed by a file left on the server.
- Files load once. Restart the server after editing one (the dev watcher restarts the worker for source changes, not for
.envedits). - The startup log names the files it loaded - never their values - and a file that cannot be parsed stops startup with its name and line number.
- On a platform that injects the whole environment, stray files can be ignored:
[env] files = falseingio.toml, orGIO_ENV_FILES=0(which wins overgio.toml, asGIO_ENV_FILES=1does the other way).
The syntax is the usual dotenv one, with quotes, multiline values, export prefixes and ${VAR} references - see .env files for the details.
The starter's .env.example
A new app ships an .env.example listing the variables it knows about, with comments, and a .gitignore that keeps .env*.local out of git. Copy it to start your own:
cp .env.example .env.local # local values and secrets, never committedThe starter's file:
# Copy to .env.local (git-ignored) and fill in. Env files load at server
# start - restart after editing. Order: .env.{mode}.local, .env.local,
# .env.{mode}, .env; the first file that sets a variable wins, and real
# environment variables always win over files.
# Session secret for createSessionStorage() and [[guards]] require_session.
# Required in production (dev generates a temporary one). Generate with:
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
# GIO_SESSION_SECRET=
# Only GIO_PUBLIC_* variables reach browser code (process.env.GIO_PUBLIC_X),
# so never put a secret in one.
# GIO_PUBLIC_SITE_NAME=My GioJS app
# The port the server listens on (overrides [server] port in gio.toml).
# Hosting platforms usually set PORT for you.
# PORT=3000Keep .env.example current as you add variables: it is the list a teammate or a deploy pipeline works from. Real secrets go in .env.local on your machine and in the host's environment in production.
Reading variables on the server
Read secrets where only the server runs: getServerSideProps, actions, route handlers, and modules only they import. Collecting them in one server-only module gives you a single place that fails loudly when something is missing:
import '@gio.js/core/server-only';
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is not set - see .env.example`);
return value;
}
export const env = {
databaseUrl: required('DATABASE_URL'),
stripeSecretKey: required('STRIPE_SECRET_KEY'),
};import { env } from '../../lib/env.server';
export async function getServerSideProps() {
const orders = await fetchOrders(env.databaseUrl);
return { props: { orders } }; // props are sent to the browser - no secrets
}
export default function Orders({ orders }) { /* ... */ }"Fails loudly" means: every URL whose module imports it answers 500 - a page renders its error page, and a route.ts answers { "error": "Internal Server Error", "digest": "..." } for every method (in development, the import error itself). The server log carries the file and the error under the same digest, and a route.ts that fails to import is also logged once at startup. The server still starts, so the rest of the app keeps serving; npx gio routes marks such a route (failed to load). The same applies to createSessionStorage() at module scope with no GIO_SESSION_SECRET in production.
getServerSideProps and everything only it imports are removed from the browser bundle, but its return value is not secret: props are serialized into the page so it can hydrate. Return the data the page needs, never a key, a token or a whole database row with fields the visitor should not see.Variables in the browser: GIO_PUBLIC_
In client code, process.env.GIO_PUBLIC_* reads are replaced with the value at build time; every other process.env.X is undefined in the browser (NODE_ENV is always available). The server renders with the same values, so the HTML and the hydrated page agree:
export default function Footer() {
return <footer>{process.env.GIO_PUBLIC_SITE_NAME}</footer>; // works on both sides
}When "build time" is depends on how you ship:
| Deploy | GIO_PUBLIC_* values are read | To change one |
|---|---|---|
npm start / gio (from source) | when the server starts and builds the client bundles | restart |
gio build standalone | during the build, from the build environment and the project's production .env files | rebuild |
gio export | during the export | re-export |
Anything named GIO_PUBLIC_* is readable by every visitor in the page's JavaScript. Use the prefix for public configuration - an API base URL, a publishable payment key, an analytics site id - and never for a secret.
Keeping server code out of the browser
A component that imports a module holding secrets would pull that module into the browser bundle. Mark such modules server-only and the mistake becomes a build error instead of a leak: import @gio.js/core/server-only at the top, or name the file *.server.ts (.tsx, .js, .jsx). A route whose client bundle reaches one is rejected - it still server-renders but does not hydrate - and the error names the import chain, in the dev overlay and the server log. See Keeping server code out of the browser.
Secrets GioJS uses
| Variable | Needed when | Notes |
|---|---|---|
GIO_SESSION_SECRET | You use sessions or require_session guards | At least 32 bytes; comma-separated to rotate (the first signs, all verify). In production, missing means createSessionStorage() throws - every page and route.ts importing the session module answers 500 - and guards deny everyone; in development an ephemeral secret is generated, and the @gio.js/core/testing kit sets a random one for tests. |
GIO_REVALIDATE_TOKEN | A CMS or script purges pages through POST /_gio/revalidate | At least 32 bytes or the server refuses to start. Without it the endpoint does not exist. |
Generate either with:
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"gio.toml has no variable substitution, and it is usually committed. Keep secrets out of it: prefer GIO_REVALIDATE_TOKEN over [revalidate] token, and protect /_gio/metrics with ip_allowlist - its token can only be set in the file, so if you use one, keep that gio.toml out of public repositories.
In production
- Set variables in the platform's environment (dashboard,
fly secrets, a systemdEnvironmentFile, Kubernetes Secrets). They win over every file, and nothing secret has to live on disk next to the app. - If you do use a file on the server, use
.env.production.local, readable only by the service's user (chmod 600). - A standalone build copies no
.envfile into its output: server variables are read at runtime from the environment or from.envfiles you place in the deploy folder. Container images built from it carry none either. - Changing a server variable needs a restart; changing a
GIO_PUBLIC_*variable needs whatever the table above says.
Tests load the same files with the same rules - see Testing. For the rest of the production setup, work through the production checklist.