Skip to content

Allow for multiple environment variable sources #16398

Description

@cofl

Describe the problem

Stems from the tail end of #16195.

Some environment values are not easily or ideally loaded from environment variables, or may desire different handling than the default explicit environment variable behavior but still otherwise act like an environment variable would. For example:

  • Docker Secrets are loaded as files in the /run directory that must be read at runtime. These, unlike environment variables, are not visible to docker inspect, and so are recommended for things that ought to be kept secret.
  • Kubernetes secrets work the same way.
  • Hashicorp Vault, OpenBao, or other remote secret stores expose their secrets over the network.
  • Runtime-only environment variables don't want to be validated at build time.

Describe the proposed solution

What I do not want is a change to how EEV currently acts. The defaults are good enough for 95% of projects. This solution only seeks to open up the possibility space and allow further customization should project maintainers choose to implement them/footgun themselves.

I would like to see an extension to the EEV API that allows for an array of providers (either as an additional export from env.js or as an alternative value for variables).

Providers could have a shape like

type IssuesResult = symbol
interface EnvironmentProvider  {
  validate?: (
    variables: Record<string, EnvVarConfig<any> | undefined>,
    value: unknown,
    name: string,
    issues: Record<string, StandardSchemaV1.Issue[]>
  ) => any,
  value?: (
    name: string,
    variables: Record<string, EnvVarConfig<any> | undefined>,
    env: Record<string, string | undefined>
  ) => unknown | Promise<unknown> | { [IssuesResult]: StandardSchemaV1.Issue[] },
  variables: Record<string, EnvVarConfig<any> | undefined>,
  init?: (
    dynamic_private_env: Readonly<Partial<typeof dynamic_private_env>>,
    dynamic_public_env: Readonly<Partial<typeof dynamic_public_env>>,
    rendered_env: Readonly<Partial<typeof rendered_env>>
  ) => void | Promise<void>,
  [Symbol.dispose]?: () => void
}
  • validate has the same signature as validate() in @sveltejs/kit/internal/env (and defaults to that function).
  • value retrieves a value to validate, accepting a name, the variable configuration for the provider, and the environment passed to set_env. The default value would be (name, _, env) => env[name].
  • variables is as per the input to defineEnvVars, so codegen should largely remain the same.
  • init allows the provider to configure itself based on variables gathered so far -- for example, connecting to a remote secret store. Another option here could be accepting a function that returns a provider, which would allow for better type safety in the provider itself, but that may complicate type generation.
  • A disposer may be provided to do cleanup.

Examples:

// lib/server/docker-secrets-provider.js
import { readFile } from "node:fs/promises"
export function dockerSecrets<
  T extends Record<string, EnvVarConfig<any>>
>(variables: const T){
  return {
    variables,
    value: (name) => readFile(`/run/secrets/${name}`, { options: "utf-8" })
      .catch((err) => ({ message: err instanceof Error ? err.message : err })
  } satisfies EnvironmentProvider
}

// lib/server/runtime-env-vars.js
import { building } from "$app/env"
export function runtimeEnvVar<
  T extends Record<string, EnvVarConfig<any>>
>(variables: const T){
  return {
    variables,
    validate: building ? () => {} ? undefined
  } satisfies EnvironmentProvider
}

// lib/server/remote-secret.js
import { defineEnvVars } from "@sveltejs/kit/env"
import Vault from "some-vault-provider"

export function remoteSecret<
  TApiToken extends string,
  TVariables extends Record<string, EnvVarConfig<any>>
>(tokenName: const TApiToken, variables: const TVariables){
  let vault: Vault | null = null
  return [
    defineEnvVars({
      [tokenName]: {}
    }),
    {
      variables,
      init(values){
          const API_TOKEN = values[tokenName]!
          vault = new Vault("https://my-secrets.example", { token: API_TOKEN })
      },
      async value(name, _, _, values){
        const result = await vault!.get(name)
        return result.data
      },
      [Symbol.dispose]: () => vault.close()
    }
  ] satisfies EnvironmentProvider[]
}

// env.js
import { dockerSecrets } from "#lib/server/docker-secrets-provider.js"
import { runtimeEnvVars } from "#lib/server/runtime-env-vars.js"
import { remoteSecret } from "#lib/server/remote-secret.js"
import { defineEnvVars, defineEnvProviders } from "@sveltejs/kit/env"

export const variables = defineEnvProviders([
  dockerSecrets({
    API_TOKEN: {} // Read from /run/secrets/API_TOKEN.
  }),
  runtimeEnvVar({
    AUTH_CLIENT_ID: {} // Skipped at build time.
  }),
  defineEnvVars({
    STILL_WORKS: {} // Acts as it does now.
  }),
  ...remoteSecret("MY_VAULT_SECRET", { // Loaded from env vars in this example
    REMOTE_SECRET: {}, // Loaded from a remote source.
  }),
])

Alternatives considered

Bypass SvelteKit's explicit environment variables and DIY. It works, but it could be nicer.

Importance

nice to have

Additional Information

No response

Activity

  1. Rich-Harris commented on Jul 18, 2026

    @Rich-Harris
    Member

    If we allowed async validators, then it would be straightforward to implement things like 'load from /run/secrets/API_TOKEN' or the vault example — you'd just return the relevant value from the schema. If we had that, you could just use spread everywhere:

    export const variables = defineEnvVars({
      ...dockerSecrets(),
      ...remoteSecret('MY_VAULT_SECRET', {...}),
      OTHER_STUFF: {}
    });

    Would that suffice?

  2. Rich-Harris commented on Jul 18, 2026

    @Rich-Harris
    Member

    Come to think of it I don't think we even need that. If there's anything async involved you can just do this:

    export const variables = defineEnvVars({
      ...await dockerSecrets(),
      ...await remoteSecret('MY_VAULT_SECRET', {...}),
      OTHER_STUFF: {}
    });
  3. cofl commented on Jul 19, 2026

    @cofl
    Author

    #16402 allowing arbitrary functions does open up the design space to where the separate value getter could go away. If those could be async and could receive the already-resolved values12, then that would be a pretty clean to go about it3.

    I'm not sure how awaiting the provider would address this though, since the validators are called in virtual:__sveltekit/env and not in env.js. You can't read a deploy-time-only file like a docker secret at build time when env.js is read for transformation4.

    Footnotes

    1. Receiving already-resolved values is necessary for things like pulling a secret from a file you then use to connect to a remote store. It would also open up options like variables conditional on the value of other variables (e.g. a feature that only cares about its variables if its flag is enabled). ↩

    2. Edit: Though I suppose dynamically importing $app/env/* would also work -- also requires async, though, and may cause some fuss with circular references. ↩

    3. init and the dispose method could be implemented within the internal logic of the variable config generator since the validators closures could inform shared state when it's needed/when all the validators have run. ↩

    4. It should be left up to the validator function to decide what to do at build time, like how runtime-only variables work at time of writing. ↩

  4. cofl commented on Jul 19, 2026

    @cofl
    Author

    Sorry I had to sleep on this to understand how all the parts fit together.

    Yes, this should be possible without any additional changes.

    With the model in your second comment, the docker secret function should look something like

    import { building, dev } from "$app/env"
    import { readFile } from "node:fs/promises"
    async function dockerSecrets<const T extends Record<string, EnvVarConfig<any>>>(
        variables: T
    ): Promise<T> {
        // Read from .env at devtime
        if (dev) return variables
    
        // Ignore at buildtime
        if (building)
            return Object.fromEntries(
                Object.entries(variables).map(([key, config]) => [key, { ...config, schema: undefined }])
            ) as unknown as T
    
        // Read from /run/secrets in production
        const values = new Map(
            await Promise.all(
                Object.keys(variables).map(
                    async (key) => [key, await readFile(`/run/secrets/${key}`)] as const
                )
            )
        )
        return Object.fromEntries(
            Object.entries(defineEnvVars(variables)).map(([name, config]) => [
                name,
                {
                    ...config,
                    schema: {
                        ["~standard"]: {
                            validate() {
                                const value = values.get(name)
                                if (!config.schema) return { value }
                                else return config.schema["~standard"].validate(value)
                            },
                        },
                    },
                },
            ])
        ) as unknown as T
    }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions