Multi-Environment Configuration in Symfony and Next.js: Secrets, .env Files, and Production Hygiene
Run a Symfony API next to a Next.js frontend for long enough and you will meet the same bug twice: a value that is correct in one app and stale in the other. Symfony environment configuration and Next.js env handling solve the same problem with different rules, and teams that treat them as interchangeable end up with drift or a deploy that quietly shipped the staging database URL to production.
This post lays out one strategy that covers both frameworks: how each dotenv hierarchy actually resolves, where encrypted secrets belong, and how values reach production on Kamal or Coolify without anyone pasting them into a server over SSH.
Symfony environment configuration: how the .env hierarchy resolves
Symfony reads up to four files, in a fixed order, with later files overriding earlier ones:
.env, committed, holds safe defaults for every environment.env.local, ignored by Git, holds your machine-specific overrides.env.$APP_ENV(for example.env.test), committed, holds per-environment defaults.env.$APP_ENV.local, ignored by Git, holds per-environment overrides on this machine
One rule sits above all four files: a real environment variable always wins. If DATABASE_URL is set in the process environment, nothing in any .env file can override it. This is the property the whole production strategy hangs on, because it means the deploy platform can inject the real values and the files in the repository stay harmless.
Two production notes. First, .env.local is skipped entirely when APP_ENV=test, which surprises people whose test suite suddenly ignores their local database override. Second, run composer dump-env prod during your build. It compiles the whole hierarchy into a single .env.local.php file, so Symfony skips parsing dotenv files on every request. On a busy API this matters, because it removes file I/O from the hot path.
Next.js plays by different rules, and the difference bites at build time
Next.js also reads .env, .env.local, .env.development, and .env.production, with a similar override order. The file order will feel familiar. The trap is when a variable is read.
Any variable prefixed with NEXT_PUBLIC_ is inlined into the JavaScript bundle at build time. The build bakes it into the compiled output as a string literal, so changing the container environment later has no effect on it. Next.js reads server-only variables (no prefix) at runtime like any Node process would.
The practical consequence: if NEXT_PUBLIC_API_URL differs between staging and production, one Docker image cannot serve both. You either build one image per environment, accept a runtime-configuration workaround (an endpoint or injected script that serves config to the client), or keep environment-specific values out of NEXT_PUBLIC_ entirely and proxy through Next.js route handlers, which read server-side variables at runtime.
Teams coming from Symfony expect "set the env var, restart the container" to work everywhere. In Next.js it works only for server-side variables. Decide per variable whether it is build-time or runtime, and write that decision down in the file itself as a comment. Future you will not remember.
Encrypted production secrets with symfony/secrets
Anything sensitive should not sit in a dotenv file at all. Symfony's secrets vault encrypts values with a public key so they can be committed safely:
php bin/console secrets:set DATABASE_PASSWORD --env=prod
This writes an encrypted file under config/secrets/prod/. The public (encryption) key is committed too. The private (decryption) key is the only thing that must never enter the repository. In production you provide it either as the file config/secrets/prod/prod.decrypt.private.php placed by your deploy process, or as the SYMFONY_DECRYPTION_SECRET environment variable containing the base64-encoded key. In CI, store that value as a masked secret and inject it only into the jobs that need it, which usually means the deploy job and not the test jobs.
Rotation without downtime relies on the override rule from earlier. Real environment variables beat vault values, so the sequence is:
- Set the new value as a plain environment variable on the platform and redeploy. The app now uses the new credential while the vault still holds the old one.
- Update the vault:
secrets:setwith the new value, commit, deploy. - Remove the temporary environment variable.
If the key itself leaks rather than a single value, secrets:generate-keys --rotate re-encrypts the whole vault with a fresh key pair in one step. Ship the new private key to production in the same deploy that ships the re-encrypted vault, and old checkouts stop being decryptable.
Stop shipping real values in .env.example
A .env.example (or the committed .env itself) should contain placeholders and safe local defaults, nothing else. The failure mode is always the same: someone copies their working .env.local over the example file "so onboarding is easier," commits it, and a real API key is now in Git history permanently. Rewriting history across every clone and fork is painful enough that in practice the key must be treated as leaked and rotated.
Two cheap safeguards: a secret scanner such as Gitleaks in CI that fails the pipeline on anything that looks like a credential, and a convention that the example file uses obviously fake values (changeme, sk_test_xxx) so a real value stands out in review.
Getting values into production on Kamal and Coolify
Both platforms follow the same principle: the repository defines which variables exist, the platform supplies what they contain.
With Kamal 2, environment variables are declared in config/deploy.yml under env, split into clear and secret. Secret values are not written in the YAML; Kamal reads them at deploy time from .kamal/secrets, which is itself a script-like file that can pull from your shell environment or a password manager CLI (1Password, Bitwarden, and friends). The values land in the container's environment, which is exactly where Symfony's override rule and Next.js server-side lookups expect them.
Coolify manages environment variables in its UI per application. The detail that matters for this post is the build-variable toggle: a variable marked as a build variable is available while the image is built, which is what a NEXT_PUBLIC_ value needs, while runtime variables only exist in the running container. Getting this toggle wrong is the single most common Coolify misconfiguration we see with Next.js apps: the build succeeds, the app runs, and the frontend silently calls the wrong API host because the baked-in value came from a fallback.
One source of truth for shared values
A Symfony API and a Next.js frontend in the same environment share more configuration than teams expect: the API base URL, the cookie domain for the session or JWT, CORS origins, the Stripe publishable key, the Sentry DSN pair. The anti-pattern is maintaining these by hand in two places, one per app, and discovering after an incident that staging and production disagreed on the cookie domain for three weeks.
Define shared values once at the deploy layer. In Kamal that is a YAML anchor or a shared section in .kamal/secrets; in Coolify it is a shared variable at the project or environment level that both applications reference. The apps then consume the same injected value under their own naming (APP_URL on the Symfony side, NEXT_PUBLIC_APP_URL or a server-side equivalent on the Next.js side). When the value changes, it changes in one place, and both deploys pick it up.
CI injection and the audit trail
In GitHub Actions, store secrets at the environment level rather than the repository level, and bind deploy jobs to those environments. That gives you two things: required reviewers can gate production deploys, and each environment holds only its own values, so a workflow targeting staging physically cannot read the production database password.
jobs:
deploy:
environment: production
steps:
- run: bin/kamal deploy
env:
KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
SYMFONY_DECRYPTION_SECRET: ${{ secrets.SYMFONY_DECRYPTION_SECRET }}
The audit question, "who changed this production secret and when," has two answers depending on where the secret lives. Values in the Symfony vault have a full Git history: the encrypted file changed in a commit, with an author and a timestamp, reviewable in a pull request. Values in a platform UI or in GitHub secrets are more opaque; GitHub records secret updates in the organization audit log, and Coolify shows a last-modified state, but neither tells you what the previous value was. Our rule of thumb: secrets that change through an engineering process (API keys, signing keys) belong in the vault where the change is a reviewed commit, while secrets owned by operations (database passwords managed by the platform) can live in the platform, with the audit log as the trail.
Frequently asked questions
Should the decryption key ever be in CI for test jobs? Usually no. Test environments should run against test credentials from plain environment variables. If the decryption key is only present in deploy jobs, a compromised test dependency cannot read production secrets.
Is committing .env to Git safe at all? The committed .env is fine as long as it holds defaults that would be harmless on a public GitHub repository. The moment a value would worry you in a leak, it belongs in the vault or the platform, and the committed file keeps a placeholder.
What about Docker Compose for local development? Compose's env_file reads a dotenv file into the container environment, which then outranks Symfony's own file hierarchy inside the container. Pick one mechanism per project for local work, either Compose injection or the framework's dotenv loading, and document it, because debugging a value that arrives through both is miserable.
Where this usually goes wrong
Configuration drift is rarely the bug that gets reported. It shows up as a CORS error only on staging, a webhook that works in one environment, a frontend calling the wrong API after an infrastructure move. When we run a code audit on a SaaS codebase, the environment and secrets setup is one of the first things we map, because it predicts a lot about how the team operates under pressure. And when we build applications with this stack, the configuration strategy above goes into the first sprint rather than waiting for a hardening phase later.
If your Symfony and Next.js setup has grown its configuration organically and you are no longer sure which values live where, write us at hello@wolf-tech.io or have a look around wolf-tech.io. A short review of the config surface is usually enough to find the risky spots.

