Skip to main content
MigrationVal Town

Transfer env vars from Val Town

Add your app's environment variables to an Easel project.

Move only the variables your code reads with Deno.env.get. Skip the ones Val Town added for its own services. Give Preview its own test credentials where a service offers them, so a preview can't change production data.

Values you already have

If you have the values elsewhere, such as in a password manager, add them in the dashboard:

  1. Open Project settings → Environment Variables.
  2. Paste the contents of a .env file into a key field.
  3. Choose Preview or Production, keep Sensitive on, and choose Add.

See Environment variables.

Values only Val Town has

A val's variables can only be read by code running in that val. Before December 10, 2026, follow the first three steps of Val Town's Transfer env vars guide to encrypt them inside the original val. That leaves private.pem and secrets.encrypted.json in a folder on your machine.

Then upload them with this script from that folder. It decrypts the values locally and sends them to the Easel API without printing or saving them, using a token limited to this project that expires in 10 minutes. Set EASEL_TOKEN to a token from Settings → Access Tokens first.

transfer-secrets.mjs
transfer-secrets.mjs
import { constants, createDecipheriv, privateDecrypt } from "node:crypto";
import { readFileSync } from "node:fs";

const [project, target, branch] = process.argv.slice(2);
if (!project || !["production", "preview"].includes(target)) {
  throw new Error("Usage: node transfer-secrets.mjs <project> <preview|production> [branch]");
}

// Decrypt the envelope from Val Town's transfer guide.
const envelope = JSON.parse(readFileSync("secrets.encrypted.json", "utf8"));
const aesKey = privateDecrypt(
  {
    key: readFileSync("private.pem"),
    padding: constants.RSA_PKCS1_OAEP_PADDING,
    oaepHash: "sha256",
  },
  Buffer.from(envelope.wrapped, "base64"),
);
const sealed = Buffer.from(envelope.encrypted, "base64");
const decipher = createDecipheriv("aes-256-gcm", aesKey, Buffer.from(envelope.iv, "base64"));
decipher.setAuthTag(sealed.subarray(-16));
const plaintext = Buffer.concat([decipher.update(sealed.subarray(0, -16)), decipher.final()]);
const values = JSON.parse(plaintext.toString("utf8"));
plaintext.fill(0);
aesKey.fill(0);

const vars = Object.entries(values).map(([key, value]) => {
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(`Invalid name: ${key}`);
  if (/^(NEXT_PUBLIC_|VITE_|PUBLIC_|NUXT_PUBLIC_)/.test(key)) {
    throw new Error(`${key} is public configuration; add it in the dashboard instead`);
  }
  if (typeof value !== "string") throw new Error(`${key} isn't a string`);
  return { key, value, target: [target], gitBranch: branch, sensitive: true };
});

// Mint a short-lived token that can only write this project's variables.
const API = "https://app.easel.sh/api/v1";
async function call(token, method, path, body) {
  const response = await fetch(`${API}${path}`, {
    method,
    headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  if (!response.ok) {
    const { error } = await response.json().catch(() => ({}));
    throw new Error(`${method} ${path} failed with ${response.status}: ${error ?? ""}`);
  }
  return response.json();
}

const workspaceToken = process.env.EASEL_TOKEN;
if (!workspaceToken) throw new Error("Set EASEL_TOKEN first");
const { project: found } = await call(workspaceToken, "GET", `/projects/${encodeURIComponent(project)}`);
const { secret } = await call(workspaceToken, "POST", "/tokens", {
  name: "transfer-secrets",
  projectIds: [found.id],
  permissions: { projects: ["read"], env: ["write"] },
  expiresIn: 600,
});

await call(secret, "POST", `/projects/${found.id}/env?upsert=true`, vars);
for (const { key } of vars) console.log(`Added ${key} to ${target}`);
node transfer-secrets.mjs link-shelf preview
node transfer-secrets.mjs link-shelf production

The script refuses names with a public prefix such as VITE_, because frameworks embed those in browser code. Add public configuration in the dashboard.

Verify

Changes apply to new deployments, so deploy again and test a feature that uses each variable. Then delete the key folder and the encryption script in the val.

Next, follow the guide for each feature your app uses.