Skip to main content

Overview

Load Env provides comprehensive support for loading secrets from files, which is essential for Docker Swarm secrets, Kubernetes secrets mounted as files, and other containerized environments. All secret functions are async and support the same type safety as environment variable functions.

What Are Docker Secrets?

Docker secrets are a secure way to store sensitive data like passwords, API keys, and certificates. In Docker Swarm and Kubernetes:
  • Secrets are stored encrypted at rest
  • They’re mounted as files in containers at runtime
  • Default location: /run/secrets/<secret-name>

Basic Usage

Secret functions work identically to env functions, but read from files:
import { secretString } from "@axel/load-env"

// Reads from /run/secrets/DATABASE_PASSWORD
const password = await secretString("DATABASE_PASSWORD")

How Secret Loading Works

When you call a secret function, the library:
1

Check for environment variable

First checks if process.env[key] contains a file path
// If DATABASE_PASSWORD=/custom/path/to/secret is set
const password = await secretString("DATABASE_PASSWORD")
// Reads from /custom/path/to/secret
2

Fall back to default path

If no environment variable exists, uses /run/secrets/<key>
// If DATABASE_PASSWORD is not set in environment
const password = await secretString("DATABASE_PASSWORD")
// Reads from /run/secrets/DATABASE_PASSWORD
3

Read and validate file

Reads the file content, trims whitespace, and validates the value
This dual behavior allows flexibility: use standard Docker secrets paths by default, or override with custom paths via environment variables.

Available Secret Functions

All secret functions mirror their env* counterparts:

secretString

Read a required string secret:
import { secretString } from "@axel/load-env"

export const API_KEY = await secretString("API_KEY")
export const JWT_SECRET = await secretString("JWT_SECRET", "dev-secret")

secretInt and secretFloat

Read numeric secrets:
import { secretInt, secretFloat } from "@axel/load-env"

export const MAX_RETRIES = await secretInt("MAX_RETRIES", 3)
export const THRESHOLD = await secretFloat("THRESHOLD")

secretBool

Read boolean secrets:
import { secretBool } from "@axel/load-env"

export const FEATURE_ENABLED = await secretBool("FEATURE_ENABLED", false)

secretUrl

Read URL secrets:
import { secretUrl } from "@axel/load-env"

export const DATABASE_URL = await secretUrl("DATABASE_URL")
export const API_ENDPOINT = await secretUrl(
  "API_ENDPOINT",
  new URL("https://api.example.com"),
)

secretUuid

Read UUID secrets:
import { secretUuid } from "@axel/load-env"

export const CLIENT_ID = await secretUuid("CLIENT_ID")
export const TENANT_ID = await secretUuid("TENANT_ID")

secretDate

Read date secrets:
import { secretDate } from "@axel/load-env"

export const LICENSE_EXPIRY = await secretDate("LICENSE_EXPIRY")

secretEnum

Read enumerated secrets:
import { secretEnum } from "@axel/load-env"

export const ENVIRONMENT = await secretEnum(
  "ENVIRONMENT",
  ["development", "staging", "production"] as const,
  "development",
)

secretStrings

Read comma-separated lists from secrets:
import { secretStrings } from "@axel/load-env"

export const ALLOWED_IPS = await secretStrings("ALLOWED_IPS")
export const ADMIN_EMAILS = await secretStrings("ADMIN_EMAILS", [
  "[email protected]",
])

Optional Secrets

Use maybeSecret* functions for optional secrets that return undefined if missing:

maybeSecretString

import { maybeSecretString } from "@axel/load-env"

const optionalKey = await maybeSecretString("OPTIONAL_API_KEY")

if (optionalKey) {
  console.log("Using API key from secret")
}

All maybeSecret Functions

import {
  maybeSecretBool,
  maybeSecretInt,
  maybeSecretFloat,
  maybeSecretUrl,
  maybeSecretUuid,
  maybeSecretDate,
  maybeSecretEnum,
  maybeSecretStrings,
} from "@axel/load-env"

const debugMode = await maybeSecretBool("DEBUG_MODE")
const port = await maybeSecretInt("CUSTOM_PORT")
const webhookUrl = await maybeSecretUrl("WEBHOOK_URL")

Error Handling

Secret functions provide detailed error messages:

Missing Secret

try {
  const key = await secretString("API_KEY")
} catch (error) {
  // Error: $API_KEY is missing
  console.error(error.message)
  console.error(error.cause) // { error, key, path }
}

File Access Error

// API_KEY=/custom/path/secret (but file doesn't exist)
try {
  const key = await secretString("API_KEY")
} catch (error) {
  // Error: Couldn't access secret at "/custom/path/secret"
  console.error(error.cause) // { error, key, path: "/custom/path/secret" }
}

Empty Secret File

try {
  const key = await secretString("API_KEY")
} catch (error) {
  // Error: The secret at "/run/secrets/API_KEY" is empty
  console.error(error.cause) // { content: "", key, path }
}

Type Validation Error

// Secret file contains "not-a-number"
try {
  const port = await secretInt("PORT")
} catch (error) {
  // TypeError: $PORT is not a number
  console.error(error.cause) // "not-a-number"
}

Implementation Details

The secret() utility function handles file reading:
export async function secret(key: string): Promise<string> {
  const envPath = process.env[key]?.trim()
  const path = envPath || `/run/secrets/${key}`

  const accessed = await access(path).catch((error: unknown) => {
    if (!envPath)
      return new Error(`$${key} is missing`, {
        cause: { error, key, path: envPath },
      })

    return new Error(`Couldn't access secret at "${path}"`, {
      cause: { error, key, path },
    })
  })
  if (accessed instanceof Error) throw accessed

  const content = await readFile(path, "utf8")
    .then(content => content.trim())
    .catch((error: unknown) => {
      return new Error(`Couldn't read secret at "${path}"`, {
        cause: { error, key, path },
      })
    })
  if (content instanceof Error) throw content

  if (!content) {
    if (!envPath)
      throw new Error(`$${key} is missing`, {
        cause: { content, key, path: envPath },
      })

    throw new Error(`The secret at "${path}" is empty`, {
      cause: { content, key, path },
    })
  }

  return content
}

Docker Compose Example

Here’s how to use secrets with Docker Compose:
docker-compose.yml
version: "3.8"

services:
  app:
    image: myapp:latest
    secrets:
      - db_password
      - api_key
    environment:
      NODE_ENV: production

secrets:
  db_password:
    file: ./secrets/db_password.txt
  api_key:
    file: ./secrets/api_key.txt
In your application:
import { loadEnv, secretString } from "@axel/load-env"

// Load environment variables first
await loadEnv()

// Then load secrets
const dbPassword = await secretString("db_password")
const apiKey = await secretString("api_key")

// Use in your application
await connectDatabase({
  password: dbPassword,
})

Kubernetes Example

With Kubernetes secrets mounted as files:
deployment.yaml
apiVersion: v1
kind: Secret
metadata:
  name: app-secrets
type: Opaque
stringData:
  DATABASE_PASSWORD: "super-secret-password"
  API_KEY: "api-key-value"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  template:
    spec:
      containers:
        - name: app
          image: myapp:latest
          volumeMounts:
            - name: secrets
              mountPath: /run/secrets
              readOnly: true
      volumes:
        - name: secrets
          secret:
            secretName: app-secrets
In your application:
import { secretString } from "@axel/load-env"

// Reads from /run/secrets/DATABASE_PASSWORD
const dbPassword = await secretString("DATABASE_PASSWORD")

// Reads from /run/secrets/API_KEY
const apiKey = await secretString("API_KEY")

Custom Secret Paths

Override the default path using environment variables:
// Set custom path via environment
process.env.DB_PASSWORD = "/etc/secrets/database/password"

// Reads from /etc/secrets/database/password
const password = await secretString("DB_PASSWORD")
This is useful for:
  • Custom secret mount paths
  • Development environments
  • Testing with mock secrets
  • Integration with non-standard secret managers

Mixing Secrets and Environment Variables

You can use both together in your application:
import { loadEnv, envString, envInt, secretString } from "@axel/load-env"

// Load .env files
await loadEnv()

// Use environment variables for non-sensitive config
export const NODE_ENV = envString("NODE_ENV")
export const PORT = envInt("PORT", 3000)

// Use secrets for sensitive data
export const DATABASE_PASSWORD = await secretString("DATABASE_PASSWORD")
export const JWT_SECRET = await secretString("JWT_SECRET")
Use environment variables for configuration and secrets for sensitive data. This separation makes it clear what needs to be protected.

Best Practices

Never Commit Secrets

Ensure secret files are gitignored:
.gitignore
# Ignore secret files
secrets/
*.secret
*.key

Use Fallbacks for Development

import { secretString } from "@axel/load-env"

// Provide dev fallback for local development
export const API_KEY = await secretString(
  "API_KEY",
  process.env.NODE_ENV === "development" ? "dev-key" : undefined,
)

Validate Critical Secrets Early

Load and validate all secrets at startup:
import { secretString, secretUrl } from "@axel/load-env"

async function loadSecrets() {
  try {
    const dbPassword = await secretString("DATABASE_PASSWORD")
    const apiUrl = await secretUrl("API_URL")
    const jwtSecret = await secretString("JWT_SECRET")

    return { dbPassword, apiUrl, jwtSecret }
  } catch (error) {
    console.error("Failed to load required secrets")
    throw error
  }
}

// Fail fast if secrets are missing
const secrets = await loadSecrets()

Handle Optional Secrets Gracefully

import { maybeSecretString, secretString } from "@axel/load-env"

// Required secret
const apiKey = await secretString("API_KEY")

// Optional secret with fallback behavior
const webhookUrl = await maybeSecretString("WEBHOOK_URL")

if (webhookUrl) {
  console.log("Webhooks enabled")
  enableWebhooks(webhookUrl)
} else {
  console.log("Webhooks disabled")
}

Next Steps

Type Safety

Learn how TypeScript types are enforced and validated throughout the library

Build docs developers (and LLMs) love