Skip to main content
Shipyard lets you attach environment variables to any project so your build commands can access API keys, tokens, and other configuration without hard-coding them in source. Secrets are encrypted before they are written to the database and decrypted only at the moment a build runs. They are never returned in plaintext by any API response.

How secrets work

Storage

Secrets are key-value pairs stored in the secrets table, one row per variable. Before a secret’s value is written to the database, it is encrypted with AES-256-GCM using the ENCRYPTION_KEY environment variable:
  • A random 16-byte IV is generated for every encryption operation, so the same plaintext produces a different ciphertext each time.
  • An authentication tag is derived from the ciphertext and stored alongside it, allowing the decryption step to detect any tampering.
  • The stored value is a single colon-delimited string: <iv-hex>:<auth-tag-hex>:<ciphertext-hex>.
Encrypted values are opaque to anyone who can only read the database — the plaintext is unrecoverable without the ENCRYPTION_KEY.

Build-time injection

When a build starts, the engine fetches the project’s secrets, decrypts each value in memory, and writes them to a .env file inside the temporary build directory:
temp/<project-name>-<timestamp>/.env
The .env file is passed to the Docker container via the --env-file flag:
docker run --rm \
  -v <buildPath>:/app \
  --user <uid>:<gid> \
  --env-file .env \
  <image-tag> \
  sh -c "cd /app && <installCommand> && <buildCommand>"
The file exists only for the duration of the build. It is removed along with the entire temp directory after the build completes, regardless of whether the build passed or failed.

Database schema

secretsTable = {
  id:        serial   // auto-generated primary key
  projectId: bigint   // foreign key → project.id (cascade delete)
  key:       text     // variable name, stored in plaintext
  value:     text     // variable value, stored encrypted (AES-256-GCM)
  createdAt: timestamp
}

Managing secrets

Adding secrets

Secrets are attached to a project at creation time or any time the project is updated:
POST /api/project
PUT  /api/project/projects/:projectId
Both endpoints accept a secrets array in the request body. Each element is a { key, value } object. The server encrypts each value before inserting it.

Deleting a secret

Individual secrets can be removed by their database ID:
DELETE /api/project/secret/:secretId
Deleting a secret is permanent and cannot be undone. There is no way to recover the original value once the row is removed. If you need to rotate a secret, add the new value first, then delete the old one.

Security implications

Static deployments are blocked for projects with secrets

If a project has any secrets, the build pipeline sets an internal hasEnvFile flag and skips the automatic deployment step after a successful build. This is intentional: a static site is served directly from the filesystem and any values baked into the build output would be publicly readable. Projects that require environment variables should be hosted behind a server-side runtime that reads secrets from the environment at request time — not embedded in a static bundle.

Secrets never appear in API responses

The value field is never decrypted when returning project or secret data through the API. Only the key (variable name) and metadata such as id and createdAt are included in responses.

Configuration

The ENCRYPTION_KEY environment variable must be set to a 32-byte value encoded as a 64-character hexadecimal string. Generate a suitable value with:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Store this value securely. If it is lost or rotated, all previously encrypted secrets become unrecoverable and will need to be re-entered.

Build docs developers (and LLMs) love