Build Artifacts the Right Way
Throwing media files, static assets and build artifacts into the same bucket in Magento deployments makes rollbacks dangerous and deployments slow. Each of these three categories has its own lifecycle, its own ownership and its own deployment strategy, and that must be reflected in the structure of the pipeline.
Table of Contents
- 1. The three categories of files in Magento
- 2. pub/media: persistent and release independent
- 3. pub/static: a build artifact, not a shared path
- 4. vendor/ and generated/: release bound artifacts
- 5. Setting up shared symlinks correctly
- 6. .gitignore and .gitattributes for clean artifacts
- 7. rsync excludes during deployment
- 8. Impact on the rollback path
- 9. Deployment approaches compared
- 10. Summary
- 11. FAQ
1. The three categories of files in Magento
Magento internally manages three fundamentally different categories of files that are frequently treated the same way in deployment processes, incorrectly so. The first category is build artifacts: files that are generated from the source code and can look different with every release, such as vendor/, generated/ and pub/static/. The second category is persistent shared files: files that are shared across releases because they contain runtime data that is not release specific, such as pub/media, var/log, var/session and app/etc/env.php. The third category is configuration files: files like app/etc/config.php that are versioned but can have environment specific variants.
The mistake teams make is handling all three categories under the same deployment model. Treating pub/media as part of the build artifact can transfer hundreds of gigabytes of product images on every deploy: unnecessary, slow and risky, because such a deployment can overwrite images that were uploaded after the last build. Treating pub/static as a shared path risks serving stale static files after a rollback that no longer match the old code.
Clearly separating these three categories is therefore not an organizational nicety but a technical requirement for deployments that are reliable, fast and rollback capable. Each category needs its own deployment strategy, and these strategies must work independently of one another.
2. pub/media: persistent and release independent
pub/media is the directory where Magento stores every image and file uploaded through the admin: product images, category banners, CMS media files and dynamically generated image variants. These files follow a lifecycle that is completely independent of the code release. They are created by admin actions and remain persistent across every release. Rolling back the code must never mean rolling back the media files.
The correct implementation for pub/media is a shared symlink: the directory physically lives in shared/pub/media/ on the server and is mounted into every release directory as a symlink. During a rollback, the code symlink is reverted, but the media symlink continues pointing at the same files. This is the only safe strategy, since it fully decouples the lifecycle of the media files from the lifecycle of the code.
# Deploy job: properly separate shared paths from build artifacts
deploy:production:
stage: deploy
script:
- |
RELEASE_ID=$(date +%Y%m%d-%H%M%S)
RELEASE_PATH="${DEPLOY_PATH}/releases/${RELEASE_ID}"
# Create release directory structure
ssh "${DEPLOY_USER}@${DEPLOY_HOST}" "mkdir -p ${RELEASE_PATH}"
# Transfer build artifacts only, exclude persistent runtime data
rsync -az --delete \
--exclude="pub/media" \
--exclude="var/log" \
--exclude="var/session" \
--exclude="var/cache" \
--exclude="var/tmp" \
--exclude="app/etc/env.php" \
./ "${DEPLOY_USER}@${DEPLOY_HOST}:${RELEASE_PATH}/"
# Link shared persistent paths into new release directory
ssh "${DEPLOY_USER}@${DEPLOY_HOST}" bash << 'REMOTE'
set -euo pipefail
RELEASE="${RELEASE_PATH}"
SHARED="${DEPLOY_PATH}/shared"
# Persistent user-generated content, never part of build artifact
ln -sfn "${SHARED}/pub/media" "${RELEASE}/pub/media"
# Runtime directories, persist across releases
ln -sfn "${SHARED}/var/log" "${RELEASE}/var/log"
ln -sfn "${SHARED}/var/session" "${RELEASE}/var/session"
# Environment-specific configuration, not in repository
ln -sfn "${SHARED}/app/etc/env.php" "${RELEASE}/app/etc/env.php"
cp "${SHARED}/app/etc/config.php" "${RELEASE}/app/etc/config.php"
REMOTE
3. pub/static: a build artifact, not a shared path
pub/static is semantically the exact opposite of pub/media: it is a pure build artifact that is generated entirely from the source code and can look completely different with every release. CSS files change with every frontend release, JavaScript bundles change with every module update, and the directory structure inside pub/static exactly mirrors the state of the code at the time of the build.
Treating pub/static as a shared path, meaning sharing it across every release, risks serving old code alongside newly built static files after a rollback. CSS classes that a new build introduced may not be referenced in the old template at all, or conversely, templates from the old code may expect CSS classes that existed in the old build but were renamed in the new build. That produces layout bugs that are hard to debug. pub/static therefore belongs in the release directory, not in the shared path.
4. vendor/ and generated/: release bound artifacts
Both vendor/ and generated/ are release bound artifacts: they belong exactly to the code state they were generated from. vendor/ contains the Composer dependencies at the versions pinned in composer.lock. generated/ contains the DI code produced by the interplay of vendor/ and app/code. Both directories must be treated as part of the build artifact and transferred into the release directory together with the code.
A common mistake is treating vendor/ as a shared path to speed up the deployment transfer. That works as long as the Composer dependencies do not change, but it breaks on the very first deployment where a module is added or a version is bumped, and it breaks for every release simultaneously, because the shared vendor path is used by all releases at once. That makes rollback impossible, since the old code then meets the new vendor/ version.
5. Setting up shared symlinks correctly
Setting up shared symlinks is a critical step in the deployment process that must happen in the right order: after the artifact has been transferred, but before the current symlink is switched over. The order matters because a partially linked release path, where some shared symlinks are set and others are not, creates inconsistent states that are hard to diagnose.
The shared directories on the server have to be created manually and populated with initial data on the first deployment. app/etc/env.php must be placed in shared/app/etc/ before the first deployment, since it contains database connection data, Redis configuration and other environment specific settings that must never end up in the repository. A script that performs this initialization once should be part of the server provisioning documentation.
# Server initialization script (run once during initial setup)
# This creates the shared directory structure for the first time
.init_shared_structure: &init_shared
script:
- |
ssh "${DEPLOY_USER}@${DEPLOY_HOST}" bash << 'REMOTE'
set -euo pipefail
BASE="${DEPLOY_PATH}"
SHARED="${BASE}/shared"
# Create base directory structure
mkdir -p "${BASE}/releases"
mkdir -p "${SHARED}/pub/media"
mkdir -p "${SHARED}/var/log"
mkdir -p "${SHARED}/var/session"
mkdir -p "${SHARED}/app/etc"
# Set correct permissions for web-accessible directories
chmod 775 "${SHARED}/pub/media"
chmod 770 "${SHARED}/var/log"
chmod 770 "${SHARED}/var/session"
# Verify that env.php exists before first deployment
if [ ! -f "${SHARED}/app/etc/env.php" ]; then
echo "ERROR: ${SHARED}/app/etc/env.php does not exist."
echo "Copy env.php to shared directory before first deployment."
exit 1
fi
echo "Shared directory structure initialized successfully"
ls -la "${SHARED}/"
REMOTE
6. .gitignore and .gitattributes for clean artifacts
The repository's .gitignore file must exclude all build artifacts and runtime data: vendor/, generated/, pub/static/, var/, pub/media. These directories have no business being in the repository. They are either produced during the build stage (vendor, generated, pub/static) or are runtime data that lives persistently on the server (pub/media, var/). A repository that contains vendor/ violates the principle of reproducible builds and slows down every Git operation considerably.
The .gitattributes file lets you control the behavior of git archive and keeps test data, development tools and CI specific configuration out of the repository export archive. For Magento projects it makes sense to exclude every file that is not needed to run the shop: tests, development tools, the .github folder, .editorconfig and similar files.
7. rsync excludes during deployment
The rsync command in the deploy job must explicitly exclude every directory that is either mounted as a shared path or does not come from the build artifact. The most important excludes are pub/media, var/log, var/session, var/cache, var/tmp and app/etc/env.php. Without these excludes, rsync would transfer an empty pub/media directory on the first deployment, which would then cover up the shared symlink.
The rsync option --delete ensures that files no longer present in the build artifact are also removed on the server. This matters when switching between releases, since otherwise files from old releases would accumulate inside the new release directory. With the correct excludes in place, --delete can be used safely without endangering persistent data.
8. Impact on the rollback path
Cleanly separating the three file categories has a direct impact on the rollback path. If pub/media is correctly implemented as a shared path, a rollback never loses media files: images uploaded after the last code release remain available after the rollback. If pub/static correctly lives as a release artifact inside the release directory, the code once again serves the static files from the old build after the rollback.
A rollback then becomes technically simple: point the current symlink at the previous release directory, clear the cache and run the verify job. Neither media files nor database data are affected, only the code and its associated build artifacts change. That is the core promise of the release model with shared symlinks: a rollback is an operation that can be executed in seconds, without data loss and without manual intervention.
9. Deployment approaches compared
How badly a wrong categorization of Magento files can hurt deployment reliability becomes especially clear when comparing a flat deployment with no separation against a release model with correct shared symlinks.
| Directory | Wrong approach | Correct approach | Rollback impact |
|---|---|---|---|
| pub/media | Part of the build artifact | Shared symlink | Without shared: images uploaded after the deploy date are lost |
| pub/static | Shared path (all releases share it) | Release artifact inside the release directory | Shared: rollback leaves old code running with new CSS |
| vendor/ | Shared path (to avoid transfer) | Release artifact inside the release directory | Shared: a Composer update affects every release at once |
| generated/ | Regenerated on the server | Build job, shipped as an artifact | Server regeneration: errors only surface in production |
| app/etc/env.php | Checked into the repository | Shared symlink from shared/ | In the repo: secrets exposed, environment mix ups possible |
The table makes clear that every wrong categorization produces its own kind of problem. pub/media as a build artifact leads to data loss. pub/static as a shared path leads to layout inconsistencies. vendor/ as a shared path makes rollbacks impossible. generated/ regenerated on the server leads to late error detection. The correct approaches solve all of these problems at once. They are not independent of one another, they combine into one coherent deployment model.
10. Summary
Sensibly separating Magento media, pub/static and build artifacts rests on the insight that these three categories follow different lifecycles. pub/media is persistent and release independent: a shared symlink. pub/static is a build artifact tied to the code: part of the release directory. vendor/ and generated/ are release bound build artifacts: also part of the release directory. app/etc/env.php is environment specific configuration: a shared symlink.
Teams that implement this separation consistently get rollbacks that execute in seconds, deployments that never transfer media files unnecessarily, and clear ownership for every file in the system. The build stage only produces what comes out of the code. The deploy job only transfers what belongs to the release. Shared symlinks connect the release to whatever must persist. The result is a deployment model that keeps working reliably under production pressure.
Magento file categories: the essentials at a glance
pub/media: shared
Persistent across every release. Shared symlink from shared/pub/media/. A rollback never touches media files, so no data loss from a code switch.
pub/static: release artifact
Bound to the build state of the code. Lives in the release directory, not in the shared path. A rollback restores the old CSS state as well.
vendor/ + generated/: release
Belongs to exactly one code state. Transferred into the release directory as a build artifact. Sharing them would block rollbacks.
env.php: shared configuration
Environment specific, never in the repository. Shared symlink from shared/app/etc/. Every release uses the same environment configuration.