Zero-downtime deployments | scotty | Spatie

 SPATIE

  Scotty
=========

spatie.be/open-source

  [Docs](https://spatie.be/docs)  [Scotty](https://spatie.be/docs/scotty/v1)  Advanced-usage  Zero-downtime deployments

 Version   v1

 Other versions for crawler [v1](https://spatie.be/docs/scotty/v1)

  Zero-downtime deployments
- [ Introduction ](https://spatie.be/docs/scotty/v1/introduction)
- [ Support us ](https://spatie.be/docs/scotty/v1/support-us)
- [ Getting started ](https://spatie.be/docs/scotty/v1/getting-started)
- [ Requirements ](https://spatie.be/docs/scotty/v1/requirements)
- [ Installation &amp; setup ](https://spatie.be/docs/scotty/v1/installation)
- [ Questions and issues ](https://spatie.be/docs/scotty/v1/questions-issues)
- [ Changelog ](https://spatie.be/docs/scotty/v1/changelog)
- [ About us ](https://spatie.be/docs/scotty/v1/about-us)

Basic Usage
-----------

- [ Your first deploy script ](https://spatie.be/docs/scotty/v1/basic-usage/your-first-deploy-script)
- [ The Scotty.sh format ](https://spatie.be/docs/scotty/v1/basic-usage/bash-format)
- [ Running tasks ](https://spatie.be/docs/scotty/v1/basic-usage/running-tasks)

Advanced Usage
--------------

- [ Doctor ](https://spatie.be/docs/scotty/v1/advanced-usage/doctor)
- [ File lookup order ](https://spatie.be/docs/scotty/v1/advanced-usage/file-lookup-order)
- [ Envoy compatibility ](https://spatie.be/docs/scotty/v1/advanced-usage/envoy-compatibility)
- [ Zero-downtime deployments ](https://spatie.be/docs/scotty/v1/advanced-usage/zero-downtime-deployments)

 Zero-downtime deployments
=========================

###  On this page

1. [ How it works ](#content-how-it-works)
2. [ Let's build the script ](#content-lets-build-the-script)
3. [ Step 1: Pull locally first ](#content-step-1-pull-locally-first)
4. [ Step 2: Clone a fresh copy on the server ](#content-step-2-clone-a-fresh-copy-on-the-server)
5. [ Step 3: Install dependencies ](#content-step-3-install-dependencies)
6. [ Step 4: Link persistent files ](#content-step-4-link-persistent-files)
7. [ Step 5: Run migrations ](#content-step-5-run-migrations)
8. [ Step 6: Go live ](#content-step-6-go-live)
9. [ Step 7: Clean up ](#content-step-7-clean-up)
10. [ Wire it all together ](#content-wire-it-all-together)
11. [ The complete script ](#content-the-complete-script)

If you deploy by pulling code directly into your application directory, there's always a moment where things are in a weird state. The new code is on disk, but Composer hasn't run yet. Or the cache still has the old config. If a request hits the server during that window, your users get an error page.

There's a simple way to avoid this: instead of updating files in place, you prepare the new release in a separate directory. When everything is ready, you swap a symlink. The switch is instant, and nobody notices.

This is how we deploy all Spatie applications. Let's build this script together, step by step.

How it works
--------------------------------------------------------------------------------------------

On your server, you'll have a directory structure like this:

```
/var/www/my-app/
├── current -> /var/www/my-app/releases/20260328-150000
├── persistent/
│   └── storage/
├── releases/
│   ├── 20260328-140000/
│   └── 20260328-150000/
└── .env
```

Let's go through each part:

- `releases/` contains a directory for every deploy. Each one is a full copy of your application.
- `current` is a symlink. It points to whichever release is currently live.
- `persistent/` holds files that should survive across deploys. Think of your `storage` directory: logs, cached files, uploaded images. You don't want to lose those every time you deploy.
- `.env` lives outside the releases, so every release automatically shares the same environment config.

Your web server's document root points to `/var/www/my-app/current/public`. When you deploy, you build everything in a new release directory. Once it's ready, you update the `current` symlink to point to it. Done. Your web server immediately serves the new code.

The clever bit: if anything goes wrong during the deploy (Composer fails, migrations break), the `current` symlink still points to the old, working release. Your users never notice.

Let's build the script
------------------------------------------------------------------------------------------------------------------------

Create a `Scotty.sh` file in your project root. We'll start with some variables:

```
#!/usr/bin/env scotty

# @servers local=127.0.0.1 remote=deployer@your-server.com

# @option branch=main

BASE_DIR="/var/www/my-app"
RELEASES_DIR="$BASE_DIR/releases"
PERSISTENT_DIR="$BASE_DIR/persistent"
CURRENT_DIR="$BASE_DIR/current"
NEW_RELEASE_NAME=$(date +%Y%m%d-%H%M%S)
NEW_RELEASE_DIR="$RELEASES_DIR/$NEW_RELEASE_NAME"
REPOSITORY="your-org/your-repo"
```

Replace `deployer@your-server.com` with your actual server, and `your-org/your-repo` with your GitHub repository. The release name is a timestamp, so every deploy gets its own unique directory. `BRANCH` defaults to `main`, but you can override it later with `scotty run deploy --branch=develop`.

Step 1: Pull locally first
------------------------------------------------------------------------------------------------------------------------------------

Before touching the server, let's make sure our local checkout is up to date:

```
# @task on:local
startDeployment() {
    git checkout $BRANCH
    git pull origin $BRANCH
}
```

This runs on your own machine. If there's a merge conflict or network issue, you'll know right away before anything happens on the server.

Step 2: Clone a fresh copy on the server
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Now we clone the repository into a new release directory on the server:

```
# @task on:remote
cloneRepository() {
    [ -d $RELEASES_DIR ] || mkdir -p $RELEASES_DIR
    [ -d $PERSISTENT_DIR ] || mkdir -p $PERSISTENT_DIR
    [ -d $PERSISTENT_DIR/storage ] || mkdir -p $PERSISTENT_DIR/storage

    cd $RELEASES_DIR
    git clone --depth 1 --branch $BRANCH git@github.com:$REPOSITORY $NEW_RELEASE_NAME
}
```

The `[ -d ... ] || mkdir` lines ensure the directories exist on the very first deploy. After that, they're no-ops. We use `--depth 1` so git only fetches the latest commit, which is much faster than cloning the full history.

At this point, you have a fresh copy of your code on the server, but the live application hasn't been touched at all. That's the key idea.

Step 3: Install dependencies
------------------------------------------------------------------------------------------------------------------------------------------

Link the shared `.env` file and run Composer:

```
# @task on:remote
runComposer() {
    cd $NEW_RELEASE_DIR
    ln -nfs $BASE_DIR/.env .env
    composer install --prefer-dist --no-dev -o
}
```

The `ln -nfs` creates a symlink from the release's `.env` to the shared one in the base directory. That way, every release uses the same database credentials and app key without you having to copy the file each time.

If your project has frontend assets, build those in a separate task:

```
# @task on:remote
buildAssets() {
    cd $NEW_RELEASE_DIR
    npm ci
    npm run build
    rm -rf node_modules
}
```

We remove `node_modules` after building because you don't need it at runtime. It just takes up space.

Step 4: Link persistent files
---------------------------------------------------------------------------------------------------------------------------------------------

Your new release has its own `storage` directory from the repo, but you want to use the shared one. Otherwise you'd lose your logs, cache, and uploaded files on every deploy. Replace it with a symlink:

```
# @task on:remote
updateSymlinks() {
    rm -rf $NEW_RELEASE_DIR/storage
    cd $NEW_RELEASE_DIR
    ln -nfs $PERSISTENT_DIR/storage storage
}
```

If your app has other directories that should persist across deploys (like a `public/uploads` folder), add more symlinks here.

Step 5: Run migrations
------------------------------------------------------------------------------------------------------------------------

```
# @task on:remote
migrateDatabase() {
    cd $NEW_RELEASE_DIR
    php artisan migrate --force
}
```

The `--force` flag is needed because Laravel won't run migrations in production without it. This runs against the new release directory, so if a migration fails, the live application is still untouched.

Step 6: Go live
---------------------------------------------------------------------------------------------------

This is the moment. We update the `current` symlink to point to the new release:

```
# @task on:remote
blessNewRelease() {
    ln -nfs $NEW_RELEASE_DIR $CURRENT_DIR

    cd $NEW_RELEASE_DIR
    php artisan config:cache
    php artisan route:cache
    php artisan view:cache
    php artisan event:cache
    php artisan cache:clear
    php artisan horizon:terminate

    sudo service php8.4-fpm restart
}
```

The `ln -nfs` updates the symlink atomically. From this exact moment, all new requests are served by the new release. There's no in-between state.

We then rebuild the caches so they match the new code, and restart PHP-FPM so it picks up the changes. If you're running Horizon for queues, terminating it lets the supervisor restart it with the new code.

Step 7: Clean up
------------------------------------------------------------------------------------------------------

Old releases pile up over time. Let's keep the three most recent ones (in case you ever need to inspect them) and delete the rest:

```
# @task on:remote
cleanOldReleases() {
    cd $RELEASES_DIR
    ls -dt $RELEASES_DIR/* | tail -n +4 | xargs rm -rf
}
```

Wire it all together
--------------------------------------------------------------------------------------------------------------------

Add a macro at the top of your file (right after the `# @servers` line) that runs every task in sequence:

```
# @macro deploy startDeployment cloneRepository runComposer buildAssets updateSymlinks migrateDatabase blessNewRelease cleanOldReleases
```

That's it. Now you can deploy with:

```
scotty run deploy
```

If any step fails, Scotty stops immediately. Since the `current` symlink hasn't been updated yet (that only happens in `blessNewRelease`), your users keep seeing the old, working version.

To deploy a different branch:

```
scotty run deploy --branch=develop
```

The complete script
-----------------------------------------------------------------------------------------------------------------

Here's everything in one file, ready to copy into your project:

```
#!/usr/bin/env scotty

# @servers local=127.0.0.1 remote=deployer@your-server.com
# @macro deploy startDeployment cloneRepository runComposer buildAssets updateSymlinks migrateDatabase blessNewRelease cleanOldReleases

# @option branch=main

BASE_DIR="/var/www/my-app"
RELEASES_DIR="$BASE_DIR/releases"
PERSISTENT_DIR="$BASE_DIR/persistent"
CURRENT_DIR="$BASE_DIR/current"
NEW_RELEASE_NAME=$(date +%Y%m%d-%H%M%S)
NEW_RELEASE_DIR="$RELEASES_DIR/$NEW_RELEASE_NAME"
REPOSITORY="your-org/your-repo"

# @task on:local
startDeployment() {
    git checkout $BRANCH
    git pull origin $BRANCH
}

# @task on:remote
cloneRepository() {
    [ -d $RELEASES_DIR ] || mkdir -p $RELEASES_DIR
    [ -d $PERSISTENT_DIR ] || mkdir -p $PERSISTENT_DIR
    [ -d $PERSISTENT_DIR/storage ] || mkdir -p $PERSISTENT_DIR/storage

    cd $RELEASES_DIR
    git clone --depth 1 --branch $BRANCH git@github.com:$REPOSITORY $NEW_RELEASE_NAME
}

# @task on:remote
runComposer() {
    cd $NEW_RELEASE_DIR
    ln -nfs $BASE_DIR/.env .env
    composer install --prefer-dist --no-dev -o
}

# @task on:remote
buildAssets() {
    cd $NEW_RELEASE_DIR
    npm ci
    npm run build
    rm -rf node_modules
}

# @task on:remote
updateSymlinks() {
    rm -rf $NEW_RELEASE_DIR/storage
    cd $NEW_RELEASE_DIR
    ln -nfs $PERSISTENT_DIR/storage storage
}

# @task on:remote
migrateDatabase() {
    cd $NEW_RELEASE_DIR
    php artisan migrate --force
}

# @task on:remote
blessNewRelease() {
    ln -nfs $NEW_RELEASE_DIR $CURRENT_DIR

    cd $NEW_RELEASE_DIR
    php artisan config:cache
    php artisan route:cache
    php artisan view:cache
    php artisan event:cache
    php artisan cache:clear
    php artisan horizon:terminate

    sudo service php8.4-fpm restart
}

# @task on:remote
cleanOldReleases() {
    cd $RELEASES_DIR
    ls -dt $RELEASES_DIR/* | tail -n +4 | xargs rm -rf
}
```

 A good
match?
-------------

### What we do best

- All things Laravel
- Custom frontend components
- Building APIs
- AI-powered features
- Simplifying things
- Clean solutions
- Integrating services

### Not our cup of tea

- WordPress themes
- Cutting corners
- Free mockups to win a job
- "Just execute the briefing"

 In short: we'd like to be a **substantial part** of your project.

 [ Get in touch via email ](mailto:info@spatie.be?subject=A%20good%20match%21&body=Tell%20us%20as%20much%20as%20you%20can%20about%0A-%20your%20online%20project%0A-%20your%20planning%0A-%20your%20budget%0A-%20%E2%80%A6%0A%0AAnything%20that%20helps%20us%20to%20start%20straightforward%21)
