Carlos Sura

Mautic & GCP: Automate Database Password Rotation with Secrets Manager

For a Mautic instance hosted on Google Cloud Platform (GCP), automating database credential rotation with Google Cloud Secret Manager introduces a common operational challenge: Mautic reads its database configuration from app/config/local.php on every request or CLI cron execution, and hardcoding static credentials breaks the moment Cloud SQL rotates the password.

Conversely, calling the Secret Manager API on every single HTTP request adds unnecessary network latency and API costs. Below is a production-ready pattern that dynamically fetches rotated credentials from Google Cloud Secret Manager and caches them locally with strict file permissions and a configurable TTL.


Prerequisites & IAM Setup

  1. Compute / GKE Workload Identity: Ensure the VM service account running Mautic has the Secret Manager Secret Accessor (roles/secretmanager.secretAccessor) IAM role bound only to the specific database secret.
  2. Composer Dependency: Install the official Google Cloud Secret Manager PHP client inside your Mautic root directory:
composer require google/cloud-secret-manager

Updating app/config/local.php with TTL Caching

Replace the static database password in app/config/local.php with the following implementation. It checks a local JSON cache file first (with a 1-hour TTL) and falls back to querying versions/latest from Google Cloud Secret Manager when the cache expires:

<?php

use Google\Cloud\SecretManager\V1\SecretManagerServiceClient;
use Google\ApiCore\ApiException;

// Use a static variable to ensure the configuration is only processed once per request.
static $mautic_parameters_config = null;

if (null === $mautic_parameters_config) {
    // Define your project and secret details here.
    define('GCP_PROJECT_ID', 'your-gcp-project');
    define('SECRET_DB_PASSWORD', 'your-secret-for-password');

    // Store the credential cache outside public web roots with restrictive permissions.
    define('CREDENTIALS_CACHE_FILE', __DIR__ . '/../cache/prod/db_credentials.json');

    // Cache lifetime in seconds (e.g., 3600 = 1 hour).
    define('CACHE_TTL_SECONDS', 3600);

    $dbCredentials = null;

    // Read credentials from the local cache if still within TTL.
    if (file_exists(CREDENTIALS_CACHE_FILE) && (time() - filemtime(CREDENTIALS_CACHE_FILE) < CACHE_TTL_SECONDS)) {
        $cachedData = file_get_contents(CREDENTIALS_CACHE_FILE);
        if ($cachedData !== false) {
            $decoded = json_decode($cachedData, true);
            if (is_array($decoded) && !empty($decoded['password'])) {
                $dbCredentials = $decoded;
            }
        }
    }

    // If the cache is expired, missing, or invalid, fetch from GCP Secret Manager.
    if (!$dbCredentials) {
        try {
            require_once __DIR__ . '/../vendor/autoload.php';

            $client = new SecretManagerServiceClient();

            function getSecretValue(SecretManagerServiceClient $client, string $projectId, string $secretId): string {
                $name = $client->secretVersionName($projectId, $secretId, 'latest');
                $response = $client->accessSecretVersion($name);
                return $response->getPayload()->getData();
            }

            $dbCredentials = [
                'password' => getSecretValue($client, GCP_PROJECT_ID, SECRET_DB_PASSWORD),
            ];

            $cacheDir = dirname(CREDENTIALS_CACHE_FILE);
            if (!is_dir($cacheDir)) {
                mkdir($cacheDir, 0700, true);
            }

            file_put_contents(CREDENTIALS_CACHE_FILE, json_encode($dbCredentials), LOCK_EX);
            chmod(CREDENTIALS_CACHE_FILE, 0600);
        } catch (ApiException | Exception $e) {
            error_log('ERROR: Could not fetch credentials from Secret Manager: ' . $e->getMessage());
            $dbCredentials = [
                'host'     => 'FETCH_FAILED',
                'name'     => 'FETCH_FAILED',
                'user'     => 'FETCH_FAILED',
                'password' => 'FETCH_FAILED',
            ];
        }
    }

    // Build the final Mautic parameters array.
    $mautic_parameters_config = [
        'db_password'      => $dbCredentials['password'],
        'db_host'          => 'localhost',
        'db_name'          => 'dbname',
        'db_user'          => 'username',
        'db_driver'        => 'pdo_mysql',
        'db_host_ro'       => null,
        'db_table_prefix'  => null,
        'db_port'          => '3306',
        'db_backup_tables' => 1,
        'db_backup_prefix' => 'bak_',
        'secret_key'       => 'replace-with-your-mautic-secret-key',
        'site_url'         => 'https://mautic.example.com',
    ];
}

$parameters = $mautic_parameters_config;

Handling Zero-Downtime Rotation

When rotating passwords in Cloud SQL or MySQL:

  1. Dual-User or Staggered Rotation: If your rotation Cloud Function updates the password in-place, delete db_credentials.json (or run php bin/console cache:clear) immediately after rotation so Mautic fetches the new secret version on the very next request without waiting for CACHE_TTL_SECONDS to expire.
  2. File Permissions & Serialization Safety: Always use json_encode() / json_decode() rather than serialize() / unserialize() for cached data, and enforce 0600 file permissions so only the www-data / PHP-FPM user can read the cached secret.