How to work around Vercel's 4kb environment restriction

At Layer3 we run our whole platform in a full-stack beautifully orchestrated environment on Vercel. All server-side and client-side code is written in TypeScript and share many of the modules and types.

Everything was going well, until one day…

Vercel has a 4kb environment variable limit. It is caused by the underlying AWS Lambda infrastructure, but whereas AWS has some solutions for proper secret management, Vercel basically says you need to roll your own.

At this time, there are so many pros of being on a platform like Vercel, and we save so much time by not having to set up complex cloud infrastructure on AWS.

So we decided to fix this.

Our solution focused on two things:

  • Secure management and deployment of secrets in preview and production
  • Keep the excellent dev experience of pulling development keys to local environments

Check out the example repository here: larskarbo/next-env-encrypt-decrypt

Meet Doppler — an environment manager

Doppler is a service that specializes on environment variable management. It sounded perfect for our usecase, they even have a Vercel integration!

However, we quickly realized that even though Doppler has a Vercel integration, it doesn’t help the 4kb problem at all. It actually just — kind of contributes to it... By adding more DOPPLER_ variables.

However, the Doppler interface and API is amazing, and we figured it we could make a working solution with some hacking.

Fetching secrets from Doppler instead of Vercel

Once you add all your environment variables on Doppler instead of Vercel, you can work around the 4kb restriction pretty easily by fetching secrets from Doppler instead of Vercel.

The only environment variable you need on Vercel is a Doppler token. The easiest way to add tokens for development, preview and production is to have both Vercel CLI and Doppler CLI installed and generate three different keys from the terminal:

echo -n "$(doppler configs tokens create vercel-gitops --config dev --plain)" | vercel env add DOPPLER_TOKEN development
echo -n "$(doppler configs tokens create vercel-gitops --config stg --plain)" | vercel env add DOPPLER_TOKEN preview
echo -n "$(doppler configs tokens create vercel-gitops --config prd --plain)" | vercel env add DOPPLER_TOKEN production

We’ll then make a script fetchSecrets.ts that fetches these variables at build time and writes them to .env.

import fs from "fs/promises";
import secrets from "@larskarbo/gitops-secrets";

async function main() {
  const payload = await secrets.providers.doppler.fetch();

  let envFile = "";

  Object.entries({
    ...payload,
  }).forEach(([key, value]) => {
    envFile += `${key}=${value}\n`;
  });

  envFile += `DOPPLER_TOKEN=${process.env.DOPPLER_TOKEN}\n`;

  await fs.writeFile(".env", envFile);
}

void main();

Changes in package.json:

"scripts": {
	...
	"build": "npm run fetch-secrets && nextjs build",
	"fetch-secrets": "ts-node fetchSecrets.ts"
}

Yes, that's all you need.

In development, you’ll simply run npm run fetch-env. This flow doesn’t add a lot of moving parts and it feels very similar to the vercel env pull workflow.

Taking it a step further with encrypted secrets

Now that we are rolling our own secrets management, why not take it a step further and improve the security?

The current environment variable setup can be a security risk. A rogue npm package could dump all the freely available process.env variables and send it to a remote server. And remember, this could be the dependency of one of your dependencies too. Most npm apps have a boatload of dependencies when you look at the dependency tree, so the surface risk area might be bigger than you think.

Our goal will be to create a system where:

  • Secrets are always encrypted, both in transit and at rest.
  • Secrets are difficult to unintentionally leak when consumed by the final application.

Many platforms have sophisticated solutions for this, like AWS KMS and Docker Secrets. The idea is that these tools hold the secret in encrypted form and provides it to the application at runtime.

We’ll solve this in a simple and custom way, with some unique considerations:

  • We need NEXT_PUBLIC_ variables to be available in the environment.
  • We want to be able to override secrets with .env.local for our local dev environments.

Building on the Doppler setup, we'll add another environment variable to Vercel, SECRETS_KEY.

gen_key () { openssl rand -base64 32 }

gen_key | vercel env add SECRETS_KEY development
gen_key | vercel env add SECRETS_KEY preview
gen_key | vercel env add SECRETS_KEY production

Now we’ll make some changes to our fetch-secrets.ts script.

It needs to:

  1. Fetch secrets from doppler.
  2. Write all NEXT_PUBLIC_ vars to .env
  3. Write all other secrets to a special file .encrypted-secrets

Commit this file into git like this, and then add it to .gitignore. This allows us to run the app without depending on the generated file.

Our super-charged fetch-secrets.ts looks like this:

import Cryptr from "cryptr";
import fs from "fs/promises";

import gitopsSecrets from "@larskarbo/gitops-secrets";
import { ENCRYPTED_SECRETS_FILE } from "../src/utils";

async function main() {
  const payload = await gitopsSecrets.providers.doppler.fetch();

  if (!process.env.SECRETS_KEY) {
    throw new Error("SECRETS_KEY is not set");
  }

  const cryptr = new Cryptr(process.env.SECRETS_KEY);

  const encryptedText = cryptr.encrypt(JSON.stringify(payload));

  await fs.writeFile(ENCRYPTED_SECRETS_FILE, encryptedText);

  let envFile = "";

  Object.entries({
    ...payload,
  })
    .filter(([key]) => key.startsWith("NEXT_PUBLIC_"))
    .forEach(([key, value]) => {
      envFile += `${key}=${value}\n`;
    });

  envFile += `DOPPLER_TOKEN=${process.env.DOPPLER_TOKEN}\n`;
  envFile += `SECRETS_KEY=${process.env.SECRETS_KEY}\n`;

  await fs.writeFile(".env", envFile);
}

void main();

Then we need to decrypt the secrets in the runtime code. We'll make a helper function for this.

let decryptedSecrets: null | {
  [key: string]: string;
} = null;

import { readFileSync } from "fs";

import Cryptr from "cryptr";
import path from "path";

export const ENCRYPTED_SECRETS_FILE = ".encrypted-secrets";

export const getSecret = (key: string) => {
  // in case you have some overrides in `.env.local`
  if (process.env.NODE_ENV === "development" && process.env[key]) {
    return process.env[key];
  }

  // only decrypt secrets the first time
  if (!decryptedSecrets) {
    if (!process.env.SECRETS_KEY) {
      return undefined;
    }

    const encryptedSecrets = readFileSync(
      path.join(process.cwd(), ENCRYPTED_SECRETS_FILE),
      "utf8"
    );
    const cryptr = new Cryptr(process.env.SECRETS_KEY);
    decryptedSecrets = JSON.parse(cryptr.decrypt(encryptedSecrets));
  }

  return decryptedSecrets?.[key];
};

Voila! Now you can use the secrets anywhere in your app like this:

// back-end
const apiKey = getSecret("API_KEY")

// front-end
const somePublicKey = process.env.NEXT_PUBLIC_KEY

Check out the working demo here: (link, github repo).

Conclusion

Vercel may have a 4kb environment restriction, but with some creative engineering, you might end up with a system that is more developer-friendly and secure than before.

This approach might be right if you are an early stage startup. When you get bigger, and have stricter requirements to managing confidential stuff, you’ll probably end up with a more complex cloud orchestrated infrastructure.

At Layer3, we use Vercel and Doppler to move fast. If you enjoyed this post and love the idea of building new types of applications that take advantage of the decentralised web, you should join our team!

Subscribe to Layer3
Receive the latest updates directly to your inbox.
Verification
This entry has been permanently stored onchain and signed by its creator.