Drupal integrations often need private keys for SFTP transfers, signed requests, certificate authentication, or other machine-to-machine operations. The difficult part is keeping those keys out of Git, public files, configuration exports, and logs while making them available to the process that needs them.
On Acquia Cloud Platform, a practical pattern is to store the key in an environment-specific nobackup directory, put only its absolute path in Drupal settings, and pass that path to the consuming library whenever supported.
This guide adapts the supplied document Securely Accessing Private Keys from Acquia Cloud's nobackup Directory in Drupal. Commands are implementation examples for your own environment; choose the key format and access policy required by the integration before provisioning credentials.
1. Understand what nobackup provides
The directory follows this structure:
/mnt/gfs/[site].[environment]/nobackup/keys/
Acquia documents this location for sensitive files outside the repository and web root. Its contents are excluded from platform file migrations between environments, including creation of on-demand environments from production, and are not covered by Cloud Platform disaster-recovery backups. Each environment therefore needs its own provisioning and recovery process. This procedure does not apply to Site Factory, which has separate guidance. See Acquia: Storing private information in the file system.
Outside the web root means the web server does not normally serve the file directly. It does not prevent an authorized PHP process, a compromised application, or an account with filesystem access from reading it. Restrictive permissions and narrowly scoped remote credentials remain essential.
Environment-specific private key
-> nobackup/keys outside the web root
-> settings.php resolves an absolute path
-> Drupal integration checks availability
-> library or process uses the key
-> application records sanitized status only
2. Prepare the directory in the correct environment
After connecting through the approved SSH account, confirm the environment without opening any credential files:
printf 'Site: %s\nEnvironment: %s\n' "$AH_SITE_GROUP" "$AH_SITE_ENVIRONMENT"
Acquia supplies these environment identifiers; see Using environment variables. Do not assume environment names are limited to dev, test, and prod when working with on-demand environments.
Create the credential directory with restrictive permissions:
: "${AH_SITE_GROUP:?Acquia site group is missing}"
: "${AH_SITE_ENVIRONMENT:?Acquia environment is missing}"
key_dir="/mnt/gfs/${AH_SITE_GROUP}.${AH_SITE_ENVIRONMENT}/nobackup/keys"
umask 077
mkdir -p "$key_dir"
chmod 700 "$key_dir"
ls -ld "$key_dir"
The examples assume the credential owner is also the identity that needs to read it. If your SSH, PHP, cron, or worker identities differ, confirm the supported ownership and access arrangement with your platform administrator. A 600 file owned by the wrong account will still be unreadable to the application. Do not solve that problem with chmod 777.
Provision separate credentials for each environment. Local development should use a dedicated local key or a mocked integration, not a copy of the production private key.
3. Generate the key type your integration expects
A PEM-encoded key for signing or certificate authentication and an SSH private key are not interchangeable merely because both are private keys. Confirm the required algorithm, encoding, and passphrase support with the remote system and client library.
Option A: RSA private key for a PEM-consuming library
This example creates a 4096-bit RSA key with OpenSSL. The guard avoids overwriting an existing credential accidentally:
pem_path="$key_dir/application_private.pem"
if [ -e "$pem_path" ]; then
echo 'Key already exists; use the rotation procedure.'
else
(
umask 077
openssl genpkey -algorithm RSA \
-pkeyopt rsa_keygen_bits:4096 \
-out "$pem_path" && chmod 600 "$pem_path"
)
fi
This example creates an unencrypted key. If policy requires an encrypted private key, use the consumer's supported encryption and an approved runtime unlocking mechanism. Do not put the passphrase in source code or a command-line argument.
Option B: SSH or SFTP key pair
If the remote system and your security policy support Ed25519:
ssh_path="$key_dir/application_ssh_key"
if [ -e "$ssh_path" ] || [ -e "$ssh_path.pub" ]; then
echo 'Key pair already exists; use the rotation procedure.'
else
(
umask 077
ssh-keygen -t ed25519 -f "$ssh_path" -N '' &&
chmod 600 "$ssh_path" &&
chmod 644 "$ssh_path.pub"
)
fi
The empty -N value deliberately creates a key without a passphrase for an unattended integration. Use that choice only when approved, with a dedicated remote account, restricted operations, and a rotation process. If an agent or another managed unlocking mechanism is available, a passphrase-protected key may fit better.
If the remote system requires RSA, use its supported RSA format and key size instead. Send the remote administrator the public key through the approved onboarding process. Never send the private key.
4. Validate without displaying the secret
Check the file and directory metadata:
ls -ld "$key_dir"
ls -l "$pem_path"
if [ -f "$pem_path" ] && [ -r "$pem_path" ]; then
echo 'PASS: private key exists and is readable by this account'
else
echo 'FAIL: check path, ownership, and permissions'
fi
Typical modes are 700 for the directory and 600 for its private-key files. Check directory traversal permissions as well as the file itself.
Validate a PEM private key without printing its contents:
openssl pkey -in "$pem_path" -check -noout
For an SSH key, derive its public key and discard the output:
ssh-keygen -y -f "$ssh_path" > /dev/null \
&& echo 'PASS: SSH private key parsed successfully'
These checks establish local readability and parseability. They do not prove that the remote system trusts the key or that the application has permission to perform its intended operation. An encrypted key may also require an interactive passphrase or the approved unlocking mechanism during validation.
5. Configure paths in settings.php
Use environment information to construct paths instead of hard-coding a production directory:
$site_group = getenv('AH_SITE_GROUP');
$environment = getenv('AH_SITE_ENVIRONMENT');
if ($site_group !== FALSE && $site_group !== '' &&
$environment !== FALSE && $environment !== '') {
$key_directory = sprintf(
'/mnt/gfs/%s.%s/nobackup/keys',
$site_group,
$environment,
);
$settings['application_private_key_path'] = $key_directory . '/application_private.pem';
$settings['application_ssh_key_path'] = $key_directory . '/application_ssh_key';
$settings['application_ssh_known_hosts_path'] = $key_directory . '/known_hosts';
}
This stores deployment-specific paths in Drupal's runtime settings, not key contents in exported configuration. Acquia also recommends considering environment-specific filenames as an additional separation measure; if you adopt those names, change the provisioning commands and settings together.
Set a separate local path in your untracked settings.local.php when needed:
$settings['application_private_key_path'] = '/absolute/local/private/application_private.pem';
Ensure that the local settings file is actually included by your bootstrap configuration. Missing credentials should disable or fail the affected integration with a controlled error; avoid making unrelated pages unavailable just because an optional partner integration is not configured locally.
6. Retrieve the path at the point of use
Drupal exposes these settings through Settings::get():
use Drupal\Core\Site\Settings;
$key_path = Settings::get('application_private_key_path');
if (!is_string($key_path) || $key_path === '') {
throw new \RuntimeException('Private key path is not configured.');
}
if (!is_file($key_path) || !is_readable($key_path)) {
throw new \RuntimeException('Private key file is unavailable.');
}
Pass the validated path to the real option supported by your integration. This is an illustrative configuration shape, not a universal SDK API:
// Adapt the option name to the specific library being used.
$options = ['private_key_file' => $key_path];
If the SDK requires key bytes instead, read the file immediately before use:
// $key_path has already passed the checks above.
$private_key = file_get_contents($key_path);
if ($private_key === FALSE || $private_key === '') {
throw new \RuntimeException('Private key could not be loaded.');
}
// Pass directly to the SDK; never log or serialize this value.
A path-based API reduces unnecessary handling in your application code; the consumer still needs to read the secret. Neither approach makes the key safe to expose in debug dumps, logger context, exception payloads, caches, or APM traces. Remove references when no longer needed, but do not assume unset() guarantees cryptographic erasure of PHP memory.
7. Use an SSH key with explicit host verification
The SSH client's identity check and the server's host-key check solve different problems. Provision a known_hosts file using host fingerprints verified through an independent trusted channel. Collecting a key with ssh-keyscan alone does not authenticate the server.
Replace the example hostname and user with the approved destination:
ssh -o BatchMode=yes \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$key_dir/known_hosts" \
-o ConnectTimeout=10 \
-i "$key_dir/application_ssh_key" \
[email protected] \
'echo CONNECTION_SUCCESSFUL'
This harmless command applies to a remote account permitted to run commands. For an SFTP-only account, use an allowed SFTP operation instead. A successful SSH command is not a substitute for testing the exact restricted protocol your application uses.
In PHP, Symfony Process accepts an argument array, avoiding local shell interpolation. See the Symfony Process documentation. Confirm the component is installed in the application's runtime dependencies before using this example:
use Drupal\Core\Site\Settings;
use Symfony\Component\Process\Process;
$key_path = Settings::get('application_ssh_key_path');
$known_hosts = Settings::get('application_ssh_known_hosts_path');
foreach ([$key_path, $known_hosts] as $path) {
if (!is_string($path) || $path === '' ||
!is_file($path) || !is_readable($path)) {
throw new \RuntimeException('SSH credential or host verification file is unavailable.');
}
}
$process = new Process([
'ssh',
'-o', 'BatchMode=yes',
'-o', 'IdentitiesOnly=yes',
'-o', 'StrictHostKeyChecking=yes',
'-o', 'UserKnownHostsFile=' . $known_hosts,
'-o', 'ConnectTimeout=10',
'-i', $key_path,
'[email protected]',
'echo CONNECTION_SUCCESSFUL',
]);
$process->setTimeout(20);
try {
$exit_code = $process->run();
}
catch (\Symfony\Component\Process\Exception\RuntimeException $exception) {
// Do not expose raw process exceptions or their command/output details.
throw new \RuntimeException('SSH connectivity check could not complete.');
}
if ($exit_code !== 0 || trim($process->getOutput()) !== 'CONNECTION_SUCCESSFUL') {
throw new \RuntimeException('SSH connectivity check failed.');
}
Keep the destination and remote command in trusted configuration or fixed code. The argument array protects the local invocation; SSH can still send a command to a remote shell, so do not concatenate request parameters into that command. Record sanitized success/failure status and a correlation identifier, not raw command output or credentials.
8. Test the actual execution context
A Drush check verifies that Drupal's CLI bootstrap and CLI user can resolve and read the configured file:
drush php:eval '
$key = \Drupal\Core\Site\Settings::get("application_private_key_path");
if (!is_string($key) || $key === "") {
echo "FAIL: path is not configured\n";
}
elseif (!is_file($key) || !is_readable($key)) {
echo "FAIL: key is unavailable to this runtime\n";
}
else {
echo "PASS: Drupal CLI can access the key file\n";
}
'
That does not prove PHP-FPM, a queue worker, or a scheduled task has identical access. Run an authenticated, authorized integration health check through the actual execution context, returning only status. Do not create a public endpoint that prints settings or key material.
Test after deployment and after environment copy operations. Check both failure behavior when the key is absent and the intended operation when the correct key is present.
9. Rotate and recover independently of code deployment
A stable configured path lets operations replace credentials without modifying application code:
- Generate a replacement under a temporary, distinct filename in the same protected directory.
- Register the corresponding public credential with the remote system while the old one remains valid, where supported.
- Test the new key explicitly before changing the active path.
- Set the correct ownership and permissions, then replace the active file using the platform's supported atomic rename procedure on the same filesystem. Avoid writing over a file while a consumer may be reading it.
- Verify through every relevant runtime. Restart or refresh long-running consumers if they retain the old key in memory.
- Revoke the old remote credential after the transition succeeds and remove retired local material according to policy.
- Record the owner, environment, fingerprint, rotation date, and recovery procedure without recording the private key in operational notes.
Maintain an approved recovery copy in a separate secret-management system, or a documented reissue-and-revocation procedure if keys must not be backed up. Recovery planning should cover restoring remote authorization as well as recreating the local file.
Troubleshooting without exposing the key
| Symptom | What to check |
|---|---|
| File not found | Environment identifiers, configured filename, and whether that environment was provisioned |
| Permission denied | Actual process identity, file ownership, and traversal permissions on parent directories |
| Drush works but web requests fail | Differences in bootstrap configuration, environment variables, and runtime identity |
| SSH rejects key permissions | Private-key mode and ownership; do not widen permissions to bypass the check |
| OpenSSL cannot parse the file | Required encoding, algorithm, and passphrase handling |
| Host verification fails | Independently verify the remote host fingerprint and approved known_hosts entry |
| Rotation appears ineffective | Active path, remote registration, and long-running consumers retaining old credentials |
| A copied environment lacks credentials | Provision its dedicated key; do not copy production credentials as a shortcut |
Production checklist
- Private keys are outside the repository, deployment artifacts, exported configuration, and web root.
- Every environment has a dedicated credential with narrowly scoped remote permissions.
- Ownership and permissions match the real execution context.
- Drupal receives a configured path and checks availability before use.
- SSH connections verify the remote host against trusted fingerprints.
- Logs, debugging tools, and traces do not capture private-key material.
- Rotation, revocation, and recovery have been tested and assigned to an owner.
The application code stays the same across environments. Provisioning supplies the right credential, Drupal resolves its path, and the integration uses it without exposing the secret through routine application output.