Framework Guide
Remix + Netlify Secrets Management
Remix keeps secrets on the server by design — loaders and actions never expose process.env to the browser. But you still need to get those secrets to the server safely. This guide walks through the full workflow: local development, Netlify deploys, and CI — all without .env files.
Remix's Server-First Model
Unlike frameworks that blur server and client code, Remix has a clear boundary. Loaders and actions run server-side. Components render on both server and client but only receive what loaders return:
| Code | Runs on | process.env access |
|---|---|---|
| loader() | Server only | Full access to all env vars |
| action() | Server only | Full access to all env vars |
| Component | Server + Client | None — only receives loader data |
| client entry | Client only | None |
This is great for security — but you still need to solve how secrets reach the server in the first place. With .env files, it's the usual problems: plaintext on disk, shared via Slack, no audit trail. secr eliminates all three.
Step 1: Set Up Your Project
npm i -g @secr/cli
secr login
secr initThis creates .secr.json in your Remix project root. Three environments are ready: development, staging, and production.
Step 2: Import Your Existing .env
# Import your development secrets
secr set --from-env .env --env development
# Import production values
secr set --from-env .env.production --env productionAll key-value pairs from your .env file are encrypted and stored in secr. Once imported, you can delete the local files.
Step 3: Local Development
Inject secrets into the Remix dev server without writing any files to disk:
# Start Remix with secrets injected
secr run -- remix dev
# Or with Vite (Remix v2+)
secr run -- remix vite:devSecrets are set as environment variables on the process. Loaders and actions access them via process.env as usual.
Tip: Update your package.json scripts so the whole team uses secr:
{
"scripts": {
"dev": "secr run -- remix vite:dev",
"dev:staging": "secr run --env staging -- remix vite:dev"
}
}Step 4: Using Secrets in Loaders & Actions
Access secrets the standard Remix way — through process.env inside server-side functions:
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
export async function loader({ request }: LoaderFunctionArgs) {
// Server-only — never sent to the browser
const response = await fetch("https://api.stripe.com/v1/charges", {
headers: {
Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
},
});
const charges = await response.json();
// Only return what the UI needs — not the secret
return json({ chargeCount: charges.data.length });
}
export default function Dashboard() {
const { chargeCount } = useLoaderData<typeof loader>();
return <p>{chargeCount} charges this month</p>;
}import type { ActionFunctionArgs } from "@remix-run/node";
export async function action({ request }: ActionFunctionArgs) {
const signature = request.headers.get("stripe-signature");
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
// Verify the Stripe webhook signature
// ...
return new Response("ok", { status: 200 });
}Key principle: Never pass raw secrets from loaders to components. If a component needs a public value (like a Stripe publishable key), pass only that specific value from the loader — not the entire process.env.
Passing Public Values to the Client
Remix doesn't have a NEXT_PUBLIC_-style convention. To expose values to client code, return them from a root loader:
export async function loader() {
return json({
ENV: {
POSTHOG_KEY: process.env.POSTHOG_KEY,
APP_URL: process.env.APP_URL,
// Only include values that are safe to be public
},
});
}
export default function App() {
const { ENV } = useLoaderData<typeof loader>();
return (
<html>
<head><Meta /><Links /></head>
<body>
<Outlet />
<script
dangerouslySetInnerHTML={{
__html: `window.ENV = ${JSON.stringify(ENV)}`,
}}
/>
<Scripts />
</body>
</html>
);
}Client code accesses values via window.ENV.POSTHOG_KEY. The key difference from Next.js: you explicitly choose what gets exposed, rather than relying on a naming prefix.
Step 5: Deploy to Netlify
The @secr/netlify-plugin injects secrets into process.env during the onPreBuild phase — before Remix compiles. Secrets stay in memory and are never written to disk.
1. Install the plugin
npm install @secr/netlify-plugin2. Create a deploy token
secr token create --name "netlify-deploy"3. Add the token to Netlify
In your Netlify site: Site configuration → Environment variables → add SECR_TOKEN with your token value.
4. Configure netlify.toml
[build]
command = "remix vite:build"
publish = "build/client"
[[plugins]]
package = "@secr/netlify-plugin"
[plugins.inputs]
org = "my-org"
project = "my-remix-app"
environment = "production"That's it. The plugin runs before remix vite:build, so all secrets are available when Remix compiles server-side code.
Per-Environment Deploys
Use Netlify deploy contexts to pull different secrets for each deploy type:
# Default: production secrets
[[plugins]]
package = "@secr/netlify-plugin"
[plugins.inputs]
org = "my-org"
project = "my-remix-app"
environment = "production"
# Branch deploys (staging, feature branches)
[context.branch-deploy.environment]
SECR_ENVIRONMENT = "staging"
# Deploy previews (pull requests)
[context.deploy-preview.environment]
SECR_ENVIRONMENT = "development"| Netlify Context | secr Environment | Use case |
|---|---|---|
| Production | production | Live site at your custom domain |
| Branch deploy | staging | Staging branch or feature branch testing |
| Deploy preview | development | Pull request previews for code review |
CI: Run Tests with Secrets
Use the secr GitHub Action to inject secrets before running your Remix test suite:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Pull secrets
uses: secr-dev/secr-action@v1
with:
token: ${{ secrets.SECR_TOKEN }}
org: my-org
project: my-remix-app
environment: staging
- run: npm ci
- run: npm test
- run: npm run buildScan for Leaked Secrets
Remix projects using the Netlify adapter often have .env and .dev.vars files sitting around. Scan your project and install a guard to prevent future leaks:
# Audit your project for leaked secrets
secr scan
# Block commits that contain secrets
secr guard installCommon Remix Patterns
Database connections in loaders
import { drizzle } from "drizzle-orm/node-postgres";
// .server.ts files are excluded from the client bundle
const db = drizzle(process.env.DATABASE_URL!);
export { db };The .server.ts convention tells Remix to exclude this file from the client bundle entirely. Combined with secr, the database URL never touches disk or the browser.
Session secrets
import { createCookieSessionStorage } from "@remix-run/node";
export const sessionStorage = createCookieSessionStorage({
cookie: {
name: "__session",
secrets: [process.env.SESSION_SECRET!],
sameSite: "lax",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
},
});Templates for required variables
Define the secrets every environment must have to prevent runtime crashes:
secr template add DATABASE_URL
secr template add SESSION_SECRET
secr template add STRIPE_SECRET_KEY
secr template add POSTHOG_KEY
# Validate before deploying
secr template validate --env productionComplete Workflow
Local dev
secr run -- remix vite:dev injects secrets into the process. No .env files on disk.
Netlify builds
@secr/netlify-plugin injects secrets into process.env during onPreBuild. In-memory only.
CI pipeline
The secr GitHub Action exports secrets for tests. Masked in logs automatically.
Secret scanning
secr guard blocks commits containing secrets. secr scan audits the full codebase.
Ship Remix apps without .env files
npm i -g @secr/cli
secr init
secr run -- remix vite:dev