Config, Secrets and the Cache

How ConfigMaps and Secrets reach Symfony's configuration, why a Kubernetes Secret is only base64, and why the cache is warmed at build time.

Syntax%env(trim:file:DATABASE_PASSWORD_FILE)%

Symfony reads its production configuration from environment variables. Kubernetes supplies them from two objects. A ConfigMap holds plain key/value data such as a hostname or a feature flag. A Secret holds the same shape of data but is treated as sensitive by access control, by kubectl describe and by encryption at rest.

Two routes into the process

The first is the environment itself: env for one key, envFrom for every key in an object. Those become real environment variables, so %env(DATABASE_URL)% resolves. The second is a volume: a ConfigMap or Secret mounted as a directory, one file per key. Then you put the path in an environment variable and read the contents with the file processor, %env(file:DATABASE_PASSWORD_FILE)%. Files are the better route for credentials, because a file is not inherited by child processes and not printed by a crash dump of the environment.

The secret that is only base64

A Secret's data values are base64-encoded, which is an encoding, not encryption. Anyone who can read the Secret can decode it in one command. The protection is role-based access control plus encryption at rest in etcd. Symfony's own encrypted vault fits here: commit the vault, and put only SYMFONY_DECRYPTION_SECRET in a Kubernetes Secret.

Warm the cache in the image

Symfony compiles the service container and other caches on the first request unless they already exist. Run cache:warmup during the image build and every pod starts warm. That also lets the container filesystem be read only.

Example

Example · php
// src/Payment/PaymentGateway.php
declare(strict_types=1);

namespace App\Payment;

use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class PaymentGateway
{
    public function __construct(
        private readonly HttpClientInterface $client,
        // PAYMENTS_API_KEY_FILE holds a path. The file processor reads that file,
        // and trim removes the trailing newline a mounted Secret usually carries.
        // In YAML this argument would be '%env(trim:file:PAYMENTS_API_KEY_FILE)%'.
        #[Autowire(env: 'trim:file:PAYMENTS_API_KEY_FILE')]
        private readonly string $apiKey,
    ) {
    }

    public function describeKey(): string
    {
        // Never log the key itself. This is what goes in a startup line.
        return str_repeat('*', 8) . substr($this->apiKey, -4);
    }
}

// With the mounted file containing "sk_live_9c2b4ef3a9\n":
// Output: ********f3a9

When to use it

  • Pass a database URL from a ConfigMap and the password from a mounted Secret file.
  • Ship an image whose container filesystem is read only, with the compiled cache baked in.
  • Keep an encrypted Symfony vault in the repository and only the decryption key in the cluster.
  • Roll out a configuration change by restarting the Deployment rather than editing pods.

More examples

A Secret mounted as files

stringData takes plain text and the API encodes it for you, which avoids a whole class of hand-rolled base64 mistakes. Each key becomes a file under the mount path, so payments-api-key is readable at /run/secrets/shop/payments-api-key and the application only ever sees a path. SYMFONY_DECRYPTION_SECRET goes in as a variable because Symfony's vault reads it from the environment. defaultMode 0400 makes the files readable only by the user the container runs as.

Example · yaml
# k8s/secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: shop-secrets
type: Opaque
stringData:
  payments-api-key: sk_live_9c2b4ef3a9
  symfony-decryption-secret: Zm9vYmFyYmF6
---
# k8s/deployment.yaml (excerpt)
    spec:
      containers:
        - name: php
          env:
            - name: PAYMENTS_API_KEY_FILE
              value: /run/secrets/shop/payments-api-key
            - name: SYMFONY_DECRYPTION_SECRET
              valueFrom:
                secretKeyRef:
                  name: shop-secrets
                  key: symfony-decryption-secret
          volumeMounts:
            - name: shop-secrets
              mountPath: /run/secrets/shop
              readOnly: true
      volumes:
        - name: shop-secrets
          secret:
            secretName: shop-secrets
            defaultMode: 0400

Warming the cache during the build

cache:warmup compiles the service container, the routing and the Twig templates into var/cache/prod, and that directory then ships inside the image. The first request to a new pod costs nothing extra, and all pods from one image run identical compiled code. composer dump-env prod flattens .env files into .env.local.php so no dotenv parsing happens at runtime. Copy the source before this step, otherwise the warmup compiles an application that is not there yet.

Example · dockerfile
# Dockerfile (the part after composer install)
ENV APP_ENV=prod
ENV APP_DEBUG=0

COPY --link composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist

COPY --link . ./
RUN composer dump-autoload --classmap-authoritative --no-dev \
 && composer dump-env prod \
 && php bin/console cache:warmup \
 && chown -R www-data:www-data var

A read-only filesystem, once the cache is baked

This is the payoff for warming the cache at build time: with nothing left to write under var/cache, the root filesystem can be read only, so a compromise cannot drop a PHP file into the document root. The writable spots are explicit and empty on every start. An emptyDir lives as long as the pod, not the container, so it survives a restart but not a replacement. If a pod now fails with a permission error on var/cache, the warmup did not actually run in the build.

Example · yaml
# k8s/deployment.yaml (excerpt)
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 33
      containers:
        - name: php
          securityContext:
            readOnlyRootFilesystem: true
            allowPrivilegeEscalation: false
          volumeMounts:
            - name: tmp
              mountPath: /tmp
            - name: php-run
              mountPath: /run/php
      volumes:
        - name: tmp
          emptyDir: {}
        - name: php-run
          emptyDir: {}

Discussion

  • Be the first to comment on this lesson.