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:

CodeRuns onprocess.env access
loader()Server onlyFull access to all env vars
action()Server onlyFull access to all env vars
ComponentServer + ClientNone — only receives loader data
client entryClient onlyNone

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

Terminal
npm i -g @secr/cli
secr login
secr init

This creates .secr.json in your Remix project root. Three environments are ready: development, staging, and production.

Step 2: Import Your Existing .env

Terminal
# Import your development secrets
secr set --from-env .env --env development

# Import production values
secr set --from-env .env.production --env production

All 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:

Terminal
# Start Remix with secrets injected
secr run -- remix dev

# Or with Vite (Remix v2+)
secr run -- remix vite:dev

Secrets 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:

package.json
{
  "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:

app/routes/dashboard.tsx
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>;
}
app/routes/webhooks.stripe.tsx
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:

app/root.tsx
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-plugin

2. 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

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:

netlify.toml
# 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 Contextsecr EnvironmentUse case
ProductionproductionLive site at your custom domain
Branch deploystagingStaging branch or feature branch testing
Deploy previewdevelopmentPull request previews for code review

CI: Run Tests with Secrets

Use the secr GitHub Action to inject secrets before running your Remix test suite:

.github/workflows/ci.yml
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 build

Scan 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:

Terminal
# Audit your project for leaked secrets
secr scan

# Block commits that contain secrets
secr guard install

Common Remix Patterns

Database connections in loaders

app/db.server.ts
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

app/sessions.server.ts
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:

Terminal
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 production

Complete 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