Symlink Switch Deployment for Magento Step by Step
AI generated
CI/CD
.yml
GitLab · Magento · Symlink · Zero Downtime · Release Structure
Symlink Switch Deployment for Magento
Step by Step

The atomic symlink switch is the technical core of zero downtime deployments for Magento. Instead of overwriting files directly, which creates inconsistency, the new release is fully prepared and then activated as current in a single kernel system call. Rollback is therefore not an emergency plan but a millisecond operation.

14 min read ln -sfn · releases/ · shared/ · current · Rollback GitLab CI/CD · Magento 2.4 · Zero Downtime

1. The Symlink Switch Concept Explained

With symlink switch deployment, also known as Capistrano style or release based deployment, every release is stored as its own directory on the server. A symlink named current always points to the currently active release. The web server (nginx, apache) is configured so that its document_root or root points to the current directory. Activating a new version only requires a single command: ln -sfn /var/www/magento/releases/20260509-120000 /var/www/magento/current. This command is atomic: from the kernel's point of view it is a single operation that either succeeds completely or fails completely.

Atomicity is the decisive factor. Overwriting files directly, for example with rsync --delete straight into the web server root, creates a window of time in which some files already have the new version while others still have the old one. During this inconsistency window, PHP classes can be loaded that are incompatible with other classes from a different release. The symlink switch eliminates this inconsistency window entirely: the change happens at kernel level in a single operation, and all subsequent requests see exclusively the new release.

For Magento this deployment model is particularly well suited because Magento has a complex dependency structure between PHP classes, generated code, static content and configuration files. An inconsistency window between these components leads to errors that are hard to reproduce and hard to debug. The symlink switch closes this window completely.

2. Preparing the Directory Structure on the Server

Before the first deployment can take place, the directory structure on the target server needs to be initialized. This setup is a one time step performed either manually or via a script. The structure consists of three main components: the releases directory, the shared directory and the current symlink. The releases directory contains numbered or timestamp based subdirectories, one per release. The shared directory contains all files and directories that are shared between releases; they should not be overwritten with every deployment. The current symlink points to the currently active release.

The shared directories are populated with their initial contents during the very first deployment. In every subsequent deployment only symlinks pointing into the shared directories are created in the new release; the content itself stays untouched. This means uploaded images in pub/media persist across all releases, logs accumulate across releases, and the database specific env.php only needs to be created once.

# One time server initialization, run manually or via a dedicated init job
# This sets up the directory structure before the first deployment

initialize:server:
  stage: deploy
  when: manual
  before_script:
    - eval $(ssh-agent -s)
    - ssh-add "$SSH_PRIVATE_KEY"
    - mkdir -p ~/.ssh && echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
  script:
    - |
      ssh "$DEPLOY_USER@$DEPLOY_HOST" bash -s <<'REMOTE'
      set -euo pipefail

      # Create base directory structure
      mkdir -p "$DEPLOY_PATH/releases"
      mkdir -p "$DEPLOY_PATH/shared/app/etc"
      mkdir -p "$DEPLOY_PATH/shared/pub/media"
      mkdir -p "$DEPLOY_PATH/shared/var/log"
      mkdir -p "$DEPLOY_PATH/shared/var/session"
      mkdir -p "$DEPLOY_PATH/shared/var/cache"

      # Set correct permissions for shared directories
      chmod 775 "$DEPLOY_PATH/shared/pub/media"
      chmod 775 "$DEPLOY_PATH/shared/var/log"
      chmod 775 "$DEPLOY_PATH/shared/var/session"

      echo "[OK] Server initialized at $DEPLOY_PATH"
      echo "[INFO] Next step: place env.php in $DEPLOY_PATH/shared/app/etc/"
      REMOTE
  environment:
    name: production

3. Shared Paths: What Gets Shared Between Releases

Deciding which paths are shared and which are kept separate per release is central to the symlink switch model. As a rule of thumb: anything that changes with the deployment process (code, generated files, static content) is per release. Anything that is environment dependent or influenced by users is shared. For Magento this results in the following split.

Shared are: app/etc/env.php (database specific credentials and environment configuration), pub/media (user generated content such as product images and CMS uploads), var/log (log files that accumulate across releases), var/session (if file based sessions are used) and optionally var/cache (if cache contents should be reused between releases, though it is often more efficient to flush the cache after the switch rather than share it). Per release are: vendor/, generated/, pub/static/, app/code/, app/design/, app/etc/config.php and all other source code files.

4. The Atomic Symlink Switch: ln -sfn in Detail

The command ln -sfn /path/to/new/release /path/to/current is the core operation of symlink switch deployment. The flags mean: -s (symbolic link), -f (force: replace an existing link), -n (no dereference: treat an existing symlink as a file rather than as the link's target). The -n flag is crucial: without it, ln -sf on an existing symlink would try to create the link inside the linked directory instead of replacing the link itself. With -n, the existing symlink is replaced directly, and that is the atomic operation.

On Linux systems, ln -sfn is internally a rename(2) syscall operation that is guaranteed to be atomic: the directory entry changes from one inode pointer to the next with no intermediate state. From the perspective of every running process, including nginx worker processes handling requests, the switch is instantaneous. Requests that are already in progress and accessing the old release can finish running. Every new request after the switch sees the new release. There is no situation in which a request points at a directory that is mid transition.

5. Preparing a New Release: Step by Step

Preparing a new release follows a fixed order that must be fully completed before the symlink switch. Only once every preparation step has succeeded is the switch allowed to happen. This principle prevents a half finished release from being activated. In practice this means: the artifact is unpacked into the release directory, shared paths are wired in as symlinks, database migrations run (if compatible with the old release, otherwise maintenance mode is required), and every other server side step runs through, and only once all of that has succeeded does the ln -sfn command follow.

Maintenance mode in Magento sets a file called var/.maintenance.flag. When this file exists, Magento shows a maintenance page to all IPs that are not allow listed. For true zero downtime, maintenance mode needs to be as short as possible or avoided entirely. With expand and contract database migrations (backward compatible schema changes) it is possible to run setup:upgrade without maintenance mode, since the new schema is compatible with the old code. Only after the symlink switch does the new code access the new schema.

deploy:production:
  stage: deploy
  before_script:
    - eval $(ssh-agent -s)
    - ssh-add "$SSH_PRIVATE_KEY"
    - mkdir -p ~/.ssh && echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
  script:
    # Transfer artifact to server
    - scp -o StrictHostKeyChecking=yes \
        "$ARTIFACT_NAME" \
        "$DEPLOY_USER@$DEPLOY_HOST:/tmp/$ARTIFACT_NAME"
    - |
      ssh -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" bash -s <<REMOTE
      set -euo pipefail

      # Generate unique release ID from timestamp
      readonly RELEASE_ID="\$(date +%Y%m%d-%H%M%S)"
      readonly RELEASE_PATH="\$DEPLOY_PATH/releases/\$RELEASE_ID"
      readonly SHARED="\$DEPLOY_PATH/shared"

      echo "[INFO] Preparing release \$RELEASE_ID"
      mkdir -p "\$RELEASE_PATH"

      # Unpack artifact into release directory
      tar -xzf "/tmp/$ARTIFACT_NAME" -C "\$RELEASE_PATH"
      rm -f "/tmp/$ARTIFACT_NAME"

      # Link shared files, env.php is environment specific and must not be in the artifact
      ln -sfn "\$SHARED/app/etc/env.php" "\$RELEASE_PATH/app/etc/env.php"

      # Link shared directories, media persists across releases
      rm -rf "\$RELEASE_PATH/pub/media"
      ln -sfn "\$SHARED/pub/media" "\$RELEASE_PATH/pub/media"
      rm -rf "\$RELEASE_PATH/var/log"
      ln -sfn "\$SHARED/var/log" "\$RELEASE_PATH/var/log"
      rm -rf "\$RELEASE_PATH/var/session"
      ln -sfn "\$SHARED/var/session" "\$RELEASE_PATH/var/session"

      # Run database migrations, use expand and contract for zero downtime
      cd "\$RELEASE_PATH"
      bin/magento setup:upgrade --keep-generated --no-interaction

      # Atomic symlink switch, this is the zero downtime moment
      ln -sfn "\$RELEASE_PATH" "\$DEPLOY_PATH/current"
      echo "[OK] Symlink switched to \$RELEASE_ID"

      # Flush cache after switch
      bin/magento cache:flush
      echo "[OK] Cache flushed"

      # Cleanup: keep only last 5 releases
      ls -1dt "\$DEPLOY_PATH/releases"/*/ | tail -n +6 | xargs --no-run-if-empty rm -rf
      echo "[OK] Old releases cleaned up"
      REMOTE
  environment:
    name: production
  needs: [verify:staging]
  when: manual
  only:
    - tags

6. Integrating the Symlink Switch into the GitLab Pipeline

In GitLab, the symlink switch deployment process is implemented as a deploy job that runs a script on the target server over SSH. The script contains every step from unpacking the artifact to the symlink switch within a single SSH connection. This matters: if every step opens its own SSH connection, there is no guarantee that the steps run atomically with respect to other processes on the server. A single SSH connection with a bash heredoc ensures that all steps run sequentially inside a controlled subshell.

A common mistake in GitLab integration is when the deploy job fails halfway through, after the symlink has already switched but before the cache flush has completed. To prevent this, use set -euo pipefail in the remote script and make sure the ln -sfn command sits as late as possible in the script, that is, only after every preparation step has succeeded. If the job then fails, the symlink still points to the old, working release.

7. Symlink Switch vs. Direct Overwrite Compared

Comparing the symlink switch model directly against the classic approach of copying files straight into the web server root makes it clear why the symlink model is the only reasonable choice for production systems.

Criterion Direct Overwrite Symlink Switch Advantage
Atomicity None: inconsistency window Kernel atomic (rename syscall) Symlink eliminates inconsistency
Rollback Requires a fresh deployment ln -sfn to previous release Symlink in milliseconds
Deploy Risk Active directory during rsync New release prepared, then switched Symlink is lower risk
Parallel Releases Not possible Several releases on the server at once Symlink enables parallelism
Storage Needs One release on the server Several releases (configurable) Direct overwrite is leaner

The only real downside of the symlink model is the increased storage requirement from keeping several releases around. With five retained releases and a 500 MB Magento package, that is 2.5 GB of storage reserved for release history. On modern servers this is not a serious obstacle and is far outweighed by the gains in safety, rollback capability and deployment quality.

8. Rollback: Back to the Previous Release in Seconds

Rollback in the symlink switch model is the most elegant feature of the entire deployment approach. The previous release already sits fully prepared inside the releases directory. A single ln -sfn command pointing at the previous release directory makes it active again. That takes milliseconds. A cache flush follows, which takes seconds. The entire rollback completes in under a minute, regardless of the size of the Magento installation.

In GitLab, a rollback job is set up with when: manual. It automatically determines the previous release by sorting the release directories by date and picking the second newest. The job can also accept a variable ROLLBACK_RELEASE, which lets a specific release be named explicitly, useful when you want to roll back not to the immediately previous release but to an older one. After the rollback, a verify job should run to confirm that the old release actually works correctly.

9. Release Retention: Cleaning Up Old Releases

Without automatic cleanup, the releases directory grows with every deployment. With daily deployments there would be 30 release directories after a month. That is not just a storage problem, it is also a clarity problem. The recommended retention is five releases: the current release plus four historical releases that can be rolled back to in an emergency. This number is configurable and should be tuned to your own rollback strategy.

Cleanup happens at the end of the deploy script, after the symlink switch has succeeded. The command ls -1dt "$DEPLOY_PATH/releases"/*/ | tail -n +6 | xargs --no-run-if-empty rm -rf lists all release directories sorted by date (newest first), skips the first five and deletes everything after that. The active current directory is not endangered by the symlink even if the underlying directory were deleted, but retention should always be configured so that the currently active release never ends up on the deletion list. With correct date ordering this is guaranteed.

rollback:production:
  stage: deploy
  when: manual
  allow_failure: false
  before_script:
    - eval $(ssh-agent -s)
    - ssh-add "$SSH_PRIVATE_KEY"
    - mkdir -p ~/.ssh && echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
  script:
    - |
      ssh -o StrictHostKeyChecking=yes "$DEPLOY_USER@$DEPLOY_HOST" bash -s <<REMOTE
      set -euo pipefail

      # Determine rollback target, use ROLLBACK_RELEASE variable if set, else previous
      if [[ -n "${ROLLBACK_RELEASE:-}" ]]; then
        TARGET_RELEASE="\$DEPLOY_PATH/releases/$ROLLBACK_RELEASE"
      else
        # Auto detect previous release (second newest by date)
        TARGET_RELEASE="\$(ls -1dt "\$DEPLOY_PATH/releases"/*/ | sed -n '2p' | tr -d '/')"
      fi

      if [[ -z "\$TARGET_RELEASE" ]] || [[ ! -d "\$TARGET_RELEASE" ]]; then
        echo "[ERROR] No rollback target found at: \$TARGET_RELEASE" >&2
        exit 1
      fi

      CURRENT_RELEASE="\$(readlink -f "\$DEPLOY_PATH/current")"
      echo "[INFO] Rolling back from: \$CURRENT_RELEASE"
      echo "[INFO] Rolling back to:   \$TARGET_RELEASE"

      # Atomic symlink switch to previous release
      ln -sfn "\$TARGET_RELEASE" "\$DEPLOY_PATH/current"
      echo "[OK] Symlink switched to: \$TARGET_RELEASE"

      # Flush cache after rollback
      cd "\$DEPLOY_PATH/current"
      bin/magento cache:flush
      echo "[OK] Cache flushed after rollback"
      REMOTE
  environment:
    name: production
  variables:
    ROLLBACK_RELEASE: ""
  only:
    - tags
    - main

10. Summary

The symlink switch deployment model is the foundation of zero downtime deployments for Magento. The release directory structure with releases/, shared/ and the current symlink is set up once on the server and then stays stable. Every new deployment creates a new release directory, prepares it fully, including shared symlinks, database migrations and cache preparation, and then activates it with a single ln -sfn command. This command is atomic, takes milliseconds and creates no inconsistency window.

The biggest lever lies in rollback capability: because every release sits fully present on the server, a rollback is not a second deployment but a millisecond operation. This fundamentally changes decision quality within a team: deployments can happen more boldly and more often, because the way back is always clear and fast. Once a team has adopted this model, nobody wants to go back to direct overwriting.

Symlink Switch Deployment for Magento: The Essentials at a Glance

Atomicity

ln -sfn is a kernel rename operation with no inconsistency window. Every new request after the switch sees exclusively the new release.

Shared vs. Per Release

env.php, pub/media, var/log and var/session are shared. vendor/, generated/, pub/static/ and all code files are per release inside the artifact.

Deploy Order

Unpack the artifact, link shared paths, run migrations, only then switch. Never switch before every preparation step has finished.

Rollback

ln -sfn to the previous release plus cache:flush. Under a minute in total, regardless of installation size.

11. FAQ: Symlink Switch Deployment for Magento

1What makes the symlink switch atomic?
The Linux kernel implements the change as a rename(2) system call, which is guaranteed to be atomic. There is no intermediate state where the symlink points at no valid directory.
2Why ln -sfn instead of ln -sf?
-n (no dereference) ensures that an existing symlink is replaced directly instead of a link being created inside the linked directory. Without -n, ln -sf shows unexpected behavior on existing directory symlinks.
3Restart the web server after a symlink change?
No. Nginx and Apache reopen the path for every request. Every new request after the switch sees the new release immediately.
4How many releases should I keep?
5 is a good default: the current release plus 4 historical releases for rollback. More releases mean more storage but give more rollback options.
5What happens to PHP sessions during the switch?
With file based sessions, var/session must be a shared path. With Redis for sessions (recommended), the switch is fully transparent.
6Symlink switch with Deployer or Ansible?
Yes. Deployer uses the same pattern internally. Ansible can do it via the file module with state: link. The concepts are the same regardless of the tool.
7setup:upgrade fails in production?
Since setup:upgrade runs before the switch, the current release stays active, which is correct behavior. Delete the new release directory, fix the issue, restart the deployment.
8Check the current active release?
readlink -f $DEPLOY_PATH/current prints the full path. The release ID in the path shows which build is currently active.
9pub/media per release instead of shared?
Not sensible. pub/media contains user generated content independent of the code. Per release would mean copying all media on every deployment, which is costly and error prone.
10Symlink switch with multiple web servers?
Switch independently on each server. A deploy script reaches all servers over SSH, one after another or in parallel. For true atomicity across multiple servers, a load balancer is needed to take servers out of rotation during the switch.