and Shared Configs in the Release Model
env.php does not belong in the repository, and it does not belong in the build artifact. Ignore that rule and you end up with a release model that breaks at the first environment switch. This article explains how app/etc, shared directories, and config.php correctly work together in the symlink release model with GitLab CI/CD.
Table of Contents
- 1. What separates env.php, config.php, and app/etc
- 2. The release model and the role of shared paths
- 3. Setting up the shared directory structure correctly
- 4. Getting env.php onto the server securely
- 5. config.php in the build artifact or in the shared folder?
- 6. GitLab pipeline: symlinks and shared files in the deploy job
- 7. Direct comparison: shared vs. not shared
- 8. Common failure patterns with env.php and shared configs
- 9. Rollback and shared files: what changes, what stays
- 10. Summary
- 11. FAQ
1. What separates env.php, config.php, and app/etc
Magento has two fundamentally different configuration files in the app/etc/ directory that are frequently confused in practice: env.php and config.php. env.php contains environment specific runtime data: database credentials, Redis connections, the crypt key, the backend URL, and session configuration. This file must never be checked into the Git repository, because it contains secrets that differ for every environment. It is created once on the target server and shared between releases through shared paths.
config.php, on the other hand, contains the module status and can optionally hold store specific configuration exported via bin/magento app:config:dump. This file is far less sensitive and can be versioned in the repository, as long as the team has deliberately decided to manage configuration through the repository. The distinction matters for the release model: anything in the repository is rolled out with every release, while anything in the shared path stays constant across all releases.
The app/etc/ directory itself looks like a simple folder but plays a critical role in the release model. Teams that ship the full directory as part of the build artifact and then copy env.php on top are working correctly. Teams that try to turn app/etc/ itself into a symlink pointing at a shared directory risk Magento's setup system reporting permission errors on the first run. The safest approach is a real folder inside the release directory, with env.php copied into it from the shared area afterward.
2. The release model and the role of shared paths
The symlink release model for Magento works on a simple principle: every release lands in its own directory under releases/. The active release is referenced by a current symlink that the web server uses as its document root. Switching from one release to the next is an atomic operation, a single ln -sfn call, and it takes milliseconds. Requests already in flight against the old release finish normally, while new requests land immediately on the new release.
Shared paths are the bridge between this isolated release concept and the persistent server state. Files and directories that stay identical across releases, or that change independently of the code lifecycle, belong in the shared/ area. The classic candidates in Magento are pub/media/, var/log/, var/session/, and app/etc/env.php. The deploy process creates symlinks to these shared resources for every new release before the current symlink is switched over.
This concept only works if the shared structure is set up before the first deployment and maintained consistently afterward. A common mistake in practice: the first deployment places env.php inside the release directory instead of the shared folder. After a rollback, or on the next deployment, the file is missing from the new release because nobody created the symlink. GitLab pipelines therefore need an explicit step that checks whether the shared folder and its critical files exist before the deploy job proceeds.
3. Setting up the shared directory structure correctly
Setting up the shared structure on the target server is a one time step, but it needs to be documented carefully. Teams that skip documentation run into trouble the moment they set up a second server or migrate to a new one: nobody remembers which directories were created manually or what permissions they need. The shared structure belongs in the infrastructure documentation, ideally as a versioned, reproducible setup script.
For a typical Magento project with multiple stores and a Hyva frontend, the shared structure on the target server looks like this. What matters most is that the var/ folder and its subdirectories are set up as symlinks pointing to a shared path that the web server user can write to. Permission problems in var/ or pub/media/ after a deploy are almost always caused by missing or incorrect symlinks.
# Initial server setup, run once before first deployment
# Script: bin/setup-shared.sh
setup:shared:
stage: .pre
when: manual
script:
- ssh "$DEPLOY_USER@$DEPLOY_HOST" "bash -s" << 'ENDSSH'
set -euo pipefail
BASE="${DEPLOY_PATH}/shared"
# Create shared directories
mkdir -p "${BASE}/app/etc"
mkdir -p "${BASE}/pub/media"
mkdir -p "${BASE}/var/log"
mkdir -p "${BASE}/var/session"
mkdir -p "${BASE}/var/cache"
# Set correct ownership for web server user
chown -R www-data:www-data "${BASE}"
echo "Shared structure ready at ${BASE}"
ENDSSH
The manual setup job in GitLab is a deliberate choice: it should not run automatically on every deployment, but it should be versioned and reproducible. Keeping it as a manual job in the pipeline guarantees that the shared structure is always set up the same way, regardless of who on the team runs it.
4. Getting env.php onto the server securely
The env.php file contains database passwords, the crypt key, and session configuration. It must never appear in the Git repository or as a GitLab CI artifact. The safest way to get env.php onto the server is a one time manual upload over an encrypted connection during the initial server setup. After that, the file lives permanently in the shared directory and is never overwritten by any deployment process.
Alternatively, env.php can be generated by a scripted step that reads GitLab CI variables. In that case, each individual value is stored as a protected and masked variable in GitLab, and a script assembles the file from those variables. This is more work, but it is fully automatable and avoids manual server access for regular deployments. For teams with several environments this approach is cleaner, because all env.php values are managed centrally in GitLab and environment scopes prevent staging secrets from ending up in production.
Regardless of the approach chosen, the deploy job must verify that env.php exists in the shared directory before switching the current symlink. Deploying to an environment without env.php inevitably leads to a blank screen or a database error in the storefront.
5. config.php in the build artifact or in the shared folder?
Whether config.php lives in the Git repository or in the shared folder has a direct effect on the release model. If config.php lives in the repository, it is rolled out with every deployment. That is the cleaner approach, because changes to the module status are tracked in version control and the build artifact is fully reproducible. Magento teams that make configuration changes via bin/magento config:set, followed by app:config:dump and a Git commit, work under this model.
If config.php lives in the shared folder, it can be changed directly on the server without triggering a new build. That is more convenient for teams that adjust store configuration frequently, but it comes with the downside that the configuration is not version controlled and has to be restored manually after a server rebuild. For production systems the first model is almost always preferable, because it enables auditing, rollback, and reproducibility.
# deploy.yml: shared symlinks and env.php placement
deploy:production:
stage: deploy
environment:
name: production
url: https://shop.example.com
script:
- ssh "$DEPLOY_USER@$DEPLOY_HOST" "bash -s" << ENDSSH
set -euo pipefail
RELEASE="${DEPLOY_PATH}/releases/$(date +%Y%m%d-%H%M%S)"
SHARED="${DEPLOY_PATH}/shared"
CURRENT="${DEPLOY_PATH}/current"
# Verify shared structure exists before deploying
test -f "${SHARED}/app/etc/env.php" \
|| { echo "ERROR: env.php missing in shared"; exit 1; }
mkdir -p "${RELEASE}"
rsync -az --delete "${CI_PROJECT_DIR}/" "${RELEASE}/"
# Link env.php from shared (never from repository)
cp "${SHARED}/app/etc/env.php" "${RELEASE}/app/etc/env.php"
# Link persistent directories from shared
rm -rf "${RELEASE}/pub/media"
ln -sfn "${SHARED}/pub/media" "${RELEASE}/pub/media"
rm -rf "${RELEASE}/var/log"
ln -sfn "${SHARED}/var/log" "${RELEASE}/var/log"
rm -rf "${RELEASE}/var/session"
ln -sfn "${SHARED}/var/session" "${RELEASE}/var/session"
# Atomic switch to new release
ln -sfn "${RELEASE}" "${CURRENT}"
echo "Release switched to ${RELEASE}"
ENDSSH
only:
- tags
6. GitLab pipeline: symlinks and shared files in the deploy job
The deploy job in GitLab is responsible for transferring the built artifact to the server and linking the shared paths correctly. This job must be idempotent: it should behave correctly even if it gets run again after a failure. Idempotency means every operation checks its state before making changes. An ln -sfn is already idempotent; a cp overwrites on every run, which is exactly the correct behavior for env.php.
An often overlooked detail: if the deploy job runs inside a Docker container on the GitLab runner, it cannot open an SSH connection with a private key unless that key is made available to the job first. This happens in a before_script section that starts the SSH agent and adds the key from a GitLab CI variable. Teams that forget this step, or implement it carelessly, end up leaving SSH keys behind in a Docker image layer.
For multi server deployments, where several web nodes need to be updated at the same time, the deploy job repeats the symlink operations for every server. The order matters here: only once every node has fully built out the new release directory does the current symlink get switched. That way all nodes run on exactly the same code at exactly the same time.
7. Direct comparison: shared vs. not shared
Deciding which files and directories belong in the shared area, and which ones get rolled out fresh with every release, is one of the most important architecture decisions in the release model. Getting it wrong leads either to data loss or to deployments that need manual fixes on every single run.
| File / Directory | Shared | In Release Artifact | Reasoning |
|---|---|---|---|
| app/etc/env.php | Yes | No | Contains secrets, environment specific |
| app/etc/config.php | Possible | Preferred | Module status is part of the release |
| pub/media/ | Yes | No | User uploads, release independent |
| pub/static/ | No | Yes (build artifact) | Rebuilt fresh for every release |
| var/log/ | Yes | No | Logs span multiple releases |
The table draws a clear line: anything that holds secrets, is generated by user interaction, or needs to stay consistent across releases belongs in the shared area. Anything that is produced by the build process and tied to a code version is part of the release artifact. This separation is not just cleaner, it also enables rollbacks that never cause data loss, because persistent data never lives inside the release directory.
8. Common failure patterns with env.php and shared configs
The most common failure pattern in practice is a missing or empty env.php after a deployment. This happens when the deploy job is supposed to copy env.php from the shared directory, but the shared directory does not exist or the file is missing. Magento does not produce a helpful error message in this case; instead you get a blank page or an internal server error. The verify step in the pipeline therefore needs to explicitly confirm that env.php exists and is syntactically valid before the smoke test hits the storefront URL.
A second common failure pattern shows up during the first deployment to a new environment: the developer copies env.php into the release directory instead of the shared directory. The first deployment works fine. The second deployment creates a new release directory that contains no env.php, because it only ever existed in the first release directory. This problem always appears when the shared structure and the deploy process were not set up together.
A third failure pattern involves config.php and the module status. If config.php lives in the shared folder and a deployment activates new modules without updating the shared config.php, Magento starts after the deployment with an inconsistent module status. This typically shows up as missing layout blocks, errors in the admin panel, or extensions that simply do not work. The fix is either to consistently version config.php in the repository, or to implement a post deploy step that runs bin/magento app:config:import.
9. Rollback and shared files: what changes, what stays
The key advantage of the symlink release model is that rolling back to an earlier release destroys no data. Because all persistent data lives in the shared area and not in the release directory, that data fits the old release just as well as it fits the new one. A rollback is semantically identical to switching the current symlink to an older release directory, followed by a cache flush.
The one exception is database migrations. If a new release includes database schema changes applied through bin/magento setup:upgrade, the database may no longer be fully compatible with the old code after a rollback. For that case, the rollback plan needs to explicitly address database compatibility, either through backward compatible schema changes (the expand contract pattern) or through a database backup taken before the deployment.
# rollback.yml: rollback to a specific previous release
rollback:production:
stage: rollback
when: manual
environment:
name: production
script:
- ssh "$DEPLOY_USER@$DEPLOY_HOST" "bash -s" << ENDSSH
set -euo pipefail
# List available releases for manual selection
echo "Available releases:"
ls -lt "${DEPLOY_PATH}/releases/" | head -10
# PREVIOUS_RELEASE must be passed as variable in manual trigger
TARGET="${DEPLOY_PATH}/releases/${ROLLBACK_TO}"
test -d "${TARGET}" || { echo "ERROR: Release not found: ${TARGET}"; exit 1; }
# Shared files remain unchanged, only the symlink switches
ln -sfn "${TARGET}" "${DEPLOY_PATH}/current"
cd "${DEPLOY_PATH}/current"
bin/magento cache:flush
echo "Rolled back to ${TARGET}"
ENDSSH
variables:
ROLLBACK_TO: "" # Set via GitLab manual job trigger
10. Summary
Handling env.php, config.php, and shared directories in the release model correctly is not an optional refinement, it is the foundation for stable Magento deployments. env.php belongs exclusively in the shared area and must never show up in the repository or in the build artifact. config.php should preferably be versioned in the repository, so that the module status is part of the release specific artifact. Directories such as pub/media/, var/log/, and var/session/ need to be linked to the shared area with symlinks before the current symlink is switched.
The deploy job in GitLab CI needs to run these steps in the right order and verify that all shared files exist before switching the current symlink. A verify job after the deployment makes sure env.php is wired up correctly and the storefront is reachable. Rollbacks work in the symlink model without data loss, as long as database compatibility is accounted for.
env.php and Shared Configs in the Release Model: the essentials at a glance
env.php
Lives in the shared folder, never versioned. Deploy job checks it exists before the symlink switch. Generating it from GitLab variables is the cleanest solution.
config.php
Preferably versioned in the repository: module status is part of the release. Post deploy app:config:import keeps everything consistent.
Shared structure
pub/media, var/log, var/session as symlinks to shared paths. One time setup, versioned setup script, fully reproducible.
Rollback
Switch the symlink to an old release, flush the cache. Shared data stays untouched. Plan database compatibility separately.