Skip to content

Sync environment variables

Variables are managed per project: one entry per key, with a value in each environment that has it. This recipe pushes a set of variables, creating the new ones and updating the others. It needs a developer key.

Redeploy to apply

A deployment reads its variables when it's built. After changing them, deploy again (or redeploy a GitHub deployment) for the new values to take effect.

Read what exists

List the project's variables to know which keys exist, and the environments' ids:

Terminal
curl "https://api.hiraiship.com/api/v1/projects/$PROJECT_ID/variables" \
  -H "Authorization: Bearer $HIRAISHIP_TOKEN"

A secret's value never comes back, from this route or any other: you can tell a secret exists, never what it holds.

Create new keys

Create variables sets up to 100 keys at once, with the same value in each listed environment. It's all or nothing: if one key already exists in one of the environments, nothing is written and the 409 lists every conflict.

Terminal
curl -X POST "https://api.hiraiship.com/api/v1/projects/$PROJECT_ID/variables" \
  -H "Authorization: Bearer $HIRAISHIP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "secret": true,
    "environmentIds": ["'"$PRODUCTION_ID"'"],
    "variables": [{ "key": "STRIPE_SECRET_KEY", "value": "sk_live_…" }]
  }'

secret applies to every variable of the request: send secrets and plain values in two requests.

Update existing keys

Update a variable takes one key. environments is the full set of environments the key should be in afterwards, each with its value:

Terminal
curl -X PATCH "https://api.hiraiship.com/api/v1/projects/$PROJECT_ID/variables/PUBLIC_API_URL" \
  -H "Authorization: Bearer $HIRAISHIP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "environments": [
      { "environmentId": "'"$PRODUCTION_ID"'", "value": "https://api.acme.dev" },
      { "environmentId": "'"$STAGING_ID"'", "value": "https://staging-api.acme.dev" }
    ]
  }'

An environment left out of environments loses the key. An environment listed without value keeps its own.

The whole sync, in a script

sync-variables.mjs
const api = 'https://api.hiraiship.com/api/v1';
const headers = { Authorization: `Bearer ${process.env.HIRAISHIP_TOKEN}`, 'Content-Type': 'application/json' };
const projectId = process.env.PROJECT_ID;
 
// What production should hold: from a file, a secret manager…
const wanted = { PUBLIC_API_URL: 'https://api.acme.dev', SENTRY_DSN: 'https://…' };
 
const { environments, variables } = await fetch(`${api}/projects/${projectId}/variables`, { headers }).then((r) => r.json());
const production = environments.find((environment) => environment.name === 'production').id;
const existing = new Set(variables.map((variable) => variable.key));
 
const created = Object.entries(wanted).filter(([key]) => !existing.has(key));
if (created.length > 0) {
  await fetch(`${api}/projects/${projectId}/variables`, {
    method: 'POST',
    headers,
    body: JSON.stringify({ secret: false, environmentIds: [production], variables: created.map(([key, value]) => ({ key, value })) }),
  });
}
 
for (const [key, value] of Object.entries(wanted).filter(([key]) => existing.has(key))) {
  const current = variables.find((variable) => variable.key === key);
  // Keep the key in the other environments it's in, with their own values.
  const others = current.environments.filter((entry) => entry.environmentId !== production).map(({ environmentId }) => ({ environmentId }));
  await fetch(`${api}/projects/${projectId}/variables/${key}`, {
    method: 'PATCH',
    headers,
    body: JSON.stringify({ environments: [{ environmentId: production, value }, ...others] }),
  });
}