Secrets and Identity in the Cloud

Keep secret values in one managed place, give the workload an identity instead of keys, and survive rotation.

Syntaxpassword: '%env(trim:file:DATABASE_PASSWORD_FILE)%'

A secret is a value that must never appear in the repository: a database password, an API key, a signing key. Two separate questions follow. Where does the value live, and how does the application prove it may read it?

A secret manager is the source

A secret manager is a service that stores secrets, keeps old versions, and records every read. The value exists in exactly one place. The cluster reads it from there and projects it into the pod, usually as a file in a read-only volume. Symfony picks it up with the file: env var processor, so configuration still refers to %env(...)% and the repository holds nothing sensitive.

Symfony's own encrypted vault is the alternative. secrets:set encrypts a value into config/secrets/prod/, which you commit; the pod needs only SYMFONY_DECRYPTION_SECRET, the base64 private key, to read it back. One secret instead of twenty.

Identity instead of keys

IAM, identity and access management, can give the workload itself an identity. The pod's service account is mapped to a cloud role; the SDK asks the platform for credentials that last minutes. There is no long-lived access key to leak, copy into a laptop, or forget to rotate.

What trips people up

Rotation. Environment variables are read once, when the PHP process starts. Changing the value in the manager changes nothing until pods restart. A mounted file re-read on use, with a short cache, picks up the new value by itself.

Example

Example · php
<?php
// src/Secret/MountedSecret.php

declare(strict_types=1);

namespace App\Secret;

use Symfony\Component\Clock\ClockInterface;

/**
 * Reads a secret from a file the platform mounts into the container,
 * re-reading it when the cached copy is older than the TTL.
 */
final class MountedSecret
{
    private ?string $value = null;

    private ?int $readAt = null;

    public function __construct(
        private readonly ClockInterface $clock,
        private readonly string $path,
        private readonly int $ttlSeconds = 60,
    ) {
    }

    public function get(): string
    {
        $now = $this->clock->now()->getTimestamp();

        if (null !== $this->value && null !== $this->readAt && $now - $this->readAt < $this->ttlSeconds) {
            return $this->value;
        }

        $raw = file_get_contents($this->path);

        if (false === $raw) {
            throw new \RuntimeException(sprintf('Secret file "%s" is not readable.', $this->path));
        }

        $this->readAt = $now;

        // The mounted file usually ends in a newline.
        return $this->value = trim($raw);
    }
}

// With a MockClock fixed at 2026-01-01 00:00:00 and the file holding "s3cr3t\n":
// $secret->get();
// Output: s3cr3t

When to use it

  • Give the pod a database password without putting it in the image or the repository.
  • Let the application write to a bucket with no access key stored anywhere.
  • Rotate an API key in the manager and have running pods pick it up.
  • Commit encrypted secrets so a value change goes through code review.

More examples

Encrypting a secret into the committed vault

Use the vault when you do not have a secret manager yet, or for values that change rarely. Everything but the private decryption key lives in git, which means code review and history apply to secret changes too. The pod gets one environment variable, SYMFONY_DECRYPTION_SECRET, holding the base64 private key; config/secrets/prod/prod.decrypt.private.php is never committed.

Example · bash
# Create the prod key pair once. The public key is committed; the private key is not.
APP_RUNTIME_ENV=prod php bin/console secrets:generate-keys

# Encrypt a value. It is written into config/secrets/prod/ and can be committed.
APP_RUNTIME_ENV=prod php bin/console secrets:set DATABASE_PASSWORD

# See which names exist, without values.
APP_RUNTIME_ENV=prod php bin/console secrets:list

# Reveal one value when you need to compare it with the manager.
APP_RUNTIME_ENV=prod php bin/console secrets:reveal DATABASE_PASSWORD

Mounting a secret and attaching an identity

The container receives a path, not a password. Because the value arrives as a file, an operator or a controller syncing from the secret manager can update it in place, and the pod sees the new content without being recreated. The service account is the other half: object storage, queues and the secret manager all authorise the pod by its identity, so there is nothing credential-shaped in this manifest.

Example · yaml
# deploy/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
spec:
  template:
    spec:
      # Mapped to a cloud role, so the SDK gets short-lived credentials
      # and no access key is stored anywhere.
      serviceAccountName: app
      containers:
        - name: php
          image: registry.example.com/app:9f3c1ab
          env:
            - name: DATABASE_PASSWORD_FILE
              value: /run/secrets/db/password
          volumeMounts:
            - name: db-credentials
              mountPath: /run/secrets/db
              readOnly: true
      volumes:
        - name: db-credentials
          secret:
            secretName: db-credentials

Reading the mounted file from configuration

Reach for the processors when a library wants the value itself and cannot be taught to re-read a file. Env var processors chain right to left: file: loads /run/secrets/db/password, trim: strips the newline, int: casts the port. The value is still resolved once per process, so this handles the 'no secret in the image' requirement but not live rotation.

Example · yaml
# config/packages/doctrine.yaml
doctrine:
    dbal:
        driver: 'pdo_pgsql'
        host: '%env(DATABASE_HOST)%'
        port: '%env(int:DATABASE_PORT)%'
        dbname: '%env(DATABASE_NAME)%'
        user: '%env(DATABASE_USER)%'
        # file: returns the file's contents; trim: removes the trailing newline.
        password: '%env(trim:file:DATABASE_PASSWORD_FILE)%'

Discussion

  • Be the first to comment on this lesson.