Separating Magento Media, pub/static and Build Artifacts the Right Way
AI generated
CI/CD
.yml
GitLab · Magento Deployment · Media · pub/static · Artifacts
Separating Magento Media, pub/static and
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.

13 min read Media · pub/static · vendor · generated · shared symlinks Magento 2.4 · GitLab CI · Zero Downtime

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.

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.

11. FAQ: Separating Magento media, pub/static and build artifacts

1Why not pub/media as a build artifact?
Because images uploaded after the build would then be missing after every deploy. pub/media belongs in the shared path: persistent and release independent.
2Why isn't pub/static a shared path?
Because pub/static changes with every release. As a shared path, a code rollback would run with the wrong static files, causing layout errors.
3Vendor as a shared path to speed things up?
No. A shared vendor/ affects every release at once during an update. Rollback becomes impossible, since old code meets new dependencies.
4What belongs in shared/ on the server?
pub/media, var/log, var/session, app/etc/env.php. Everything that persists across releases and has no release specific content.
5How to initialize pub/media on the first deployment?
Create shared/pub/media/ during server setup. Sync existing media files in from a backup with rsync. After that, every deploy job sets the symlink automatically.
6How to set rsync excludes correctly?
Exclude every shared path and runtime data item: pub/media, var/log, var/session, var/cache, app/etc/env.php. Only then is --delete safe to use.
7What happens to pub/media during a rollback?
Nothing. pub/media lives in the shared path and is untouched by the rollback. Every image uploaded after the last deploy stays fully intact.
8Must generated/ always be transferred as an artifact?
Yes. generated/ matches vendor/ and app/code/ exactly. Regenerating it on the server is possible, but errors then surface in production instead of the build job.
9How to keep var/cache out of the artifact?
Exclude var/ entirely from the artifact and from rsync. var/cache and var/view_preprocessed are pure runtime caches that get rebuilt on the server.
10What permissions does pub/media need on the server?
775 with the deploy user as owner and www-data as the group. The web server needs write access for uploads, adjusted to the specific server configuration.