---
url: /getting-started/deployment.md
---
# Deployment

Evolve comes with built-in rsync file deployment over SSH. Every deploy builds a new release directory on the server, runs the migrations and optimizations there, and switches a `current` symlink to it once everything succeeded. The environments a project deploys to are declared in `config/evolve.php`, see [Environments](/getting-started/environments).

## Configure SSH connection

Before running your first deployment, configure the SSH connection for your server in the `deployment` section of `config/evolve.php`.

##### Example

```php [config/evolve.php]
'deployment' => [
    'environments' => [
        'live'    => ['type' => 'live',    'target' => 'www'],
        'staging' => ['type' => 'staging', 'target' => 'staging'],
    ],
    'server' => [
        'user' => env('DEPLOYMENT_USER', 'web161'),
        'host' => env('DEPLOYMENT_HOST', 'web.onlime.ch'),
        'path' => env('DEPLOYMENT_PATH', 'webhome/public_html'),
        'php-bin' => env('DEPLOYMENT_PHP_BIN', 'php84'),
    ],
    ...
],
```

This example deploys `live` to `web161@web.onlime.ch:~/webhome/public_html/www`. The web server's document root for that host points at `www/current/public`.

## Directory layout on the server

&#x20;Since 2.12 an environment's target directory looks like this:

```
www/                                  the target of one environment
├── current -> releases/20260926143012
├── releases/
│   ├── 20260920101500/
│   ├── 20260925090000/
│   └── 20260926143012/               the live release
│       ├── .env
│       ├── public/
│       │   ├── img/                  rendered image variants, per release
│       │   └── page-cache/           static page cache, per release
│       └── storage/
│           ├── app -> ../../../shared/storage/app
│           ├── framework/
│           └── logs/
└── shared/
    └── storage/
        └── app/                      uploads, one copy per environment
            └── evolve/uploads/media/
```

Everything under `storage/app` (media uploads under `evolve/uploads`, lead attachments, anything a project stores on a local disk rooted there) lives once in `shared/storage/app`. The rest of a release is its own: code, `.env`, `public/img`, `public/page-cache`, logs and framework caches. A new release is created as a hard-linked copy (`cp -al`) of the live one, so unchanged files cost no disk space.

## Shared storage

&#x20;Uploads used to live inside each release. `storage/app` is excluded from the rsync, so copying the previous release was the only thing carrying uploads forward, and an upload that reached an old release after it had been copied (a PHP worker still resolving the old path, for hours in one observed case) existed in that release only. The purge then deleted it. Shared storage removes that failure: a release is never the only copy of an upload, so purging one cannot lose any.

### What a deploy does

1. **Prepare.** The new release is created and linked to `shared/storage/app` before any file is uploaded. If it was copied with a real `storage/app` directory, that directory is merged into the shared one first.
2. **Upload, migrate, optimize, go live.** Unchanged.
3. **After go-live**, every older release is merged and linked too. That picks up what the previous release received while the deploy ran, and a worker that still resolves an old release path now writes into the shared directory.
4. **Purge.** Old releases beyond `keep-releases` are deleted, except the live one and any release that still carries its own `storage/app`. The purge does not run at all when step 3 failed.

The merge uses `rsync --ignore-existing --link-dest`: files are hard linked, not copied, so it costs neither time nor disk space, and a file the shared directory already holds is never overwritten.

### The first deploy with 2.12

There is nothing to configure and no manual migration. The first deploy with 2.12 migrates the environment: it creates `shared/storage/app`, moves the uploads of the new release and then of every older release into it, and replaces each `storage/app` with the symlink. `current/storage/app/...` keeps resolving, so `evolve:env:setup`, the Envoy media tasks and the page cache work unchanged.

Verify it on the server:

```bash
php artisan evolve:ssh live

ls -l current/storage/app                 # app -> ../../../shared/storage/app
find releases -maxdepth 3 -path '*/storage/app' ! -type l
                                          # no output: every release is linked
ls -d releases/*/storage/app.migrated     # no output: no move was interrupted
du -sh shared/storage/app                 # roughly the size of the old uploads
```

Then upload a test image in the panel and check that it appears under `shared/storage/app/evolve/uploads/media/`.

::: warning Backups and scripts on the server
Anything that reads `releases/<release>/storage/app` directly (backup jobs, sync scripts, cron tasks) must read `shared/storage/app` or go through `current/storage/app` from now on.
:::

### When linking fails

If an older release cannot be linked after go-live, the deploy says so:

> Releases could not be linked to the shared storage. The new release is live and uses shared/storage/app, but an older release still carries its own storage/app. No releases were purged; run the deploy again or check the directory on the server.

The site is fine at that point. Run the deploy again; if it keeps failing, look for a release whose `storage/app` is a real directory (the `find` command above) and for a leftover `storage/app.migrated`, typically caused by permissions or a full disk.

### Rollback and shared storage

`php artisan deploy --rollback` links every release to the shared directory first, then switches `current` to the previous release and deletes the one rolled back from. The release rolled back to therefore serves the shared uploads, including those that arrived after it was replaced, and the deleted release cannot hold the only copy of anything. A rollback to a release deployed before 2.12 is safe for the same reason: it is linked before it goes live.

Deploying an older Evolve version afterwards keeps working too: that version copies the live release with `cp -al`, which copies the `storage/app` symlink as a symlink.

## Deployment

Start a full deployment with the `deploy` command. Without an argument it deploys to `DEPLOYMENT_DEFAULT` (`live` unless set):

```bash
php artisan deploy
php artisan deploy staging
```

A deploy to live asks you to type the environment name; `--force` skips the confirmation on other types only.

#### Simulating a deployment

Use the `--dry-run` flag to simulate the deployment without actually deploying anything.

```bash
php artisan deploy --dry-run
```

#### Deploy a single file or folder

```bash
php artisan deploy --file=public/.htaccess
```

#### Deploy without purging old releases

Use the `--keep-releases` flag to keep all releases on the server.

```bash
php artisan deploy --keep-releases
```

#### Deploy without flushing caches

Use the `--keep-caches` flag to skip `optimize:clear` on the server. The page cache of the new release is cleared either way.

```bash
php artisan deploy --keep-caches
```

### What runs on the server

In the new release, before it goes live: `migrate --force`, `evolve:clear-page-cache`, `optimize:clear` (unless `--keep-caches`), `optimize` and `event:cache`. After go-live, the commands in `hooks.after` run in `current`. Hooks are plain shell commands, so name the PHP binary of the environment:

```php [config/evolve.php]
'hooks' => [
    'before' => [
        // run locally before deploying, e.g. 'yarn --cwd theme build'
    ],
    'after' => [
        'php84 artisan evolve:images:warm --missing',
        'php84 artisan evolve:rebuild-page-cache',
    ],
],
```

`evolve:images:warm --missing` queues image variants that do not exist yet ([Image Rendering](/frontend/image-rendering#queued-conversions)); `evolve:rebuild-page-cache` warms the page cache the deploy just cleared ([Page Cache](/site/page-cache)).

## Cron jobs

Onlime has no process supervisor, so everything that must run in the background runs from cron. Two entries per environment, both pointing at `current` so they follow every deploy:

```bash
# Laravel scheduler: health checks, Shopify import and purge, pruning
* * * * * cd ~/webhome/public_html/www/current && php84 artisan schedule:run >> /dev/null 2>&1

# Queue worker: every queued job (only needed when QUEUE_CONNECTION is not sync)
* * * * * cd ~/webhome/public_html/www/current && php84 artisan queue:work --stop-when-empty --max-time=55 >> /dev/null 2>&1
```

Adjust the path to the environment's target and `php84` to its `php-bin`. The worker starts every minute, works until the queue is empty or 55 seconds have passed, and exits, so two workers never overlap for long and a new release is picked up within a minute.

::: warning Switching away from sync
Once `QUEUE_CONNECTION` is not `sync`, every queued job waits for the worker: image conversions, image metadata, server side tracking events, the scheduled Shopify product and collection import, checkout recovery and back in stock mails. Add the worker line in the same step as the `.env` change.
:::

To verify: `crontab -l` over SSH lists both lines. `/evolve/health` shows the Scheduler check green, and the Queue check green once `QUEUE_CONNECTION` is not `sync` (see [Health Checks](/site/health-checks)).

::: tip A queue needs a jobs table
`QUEUE_CONNECTION=database` stores jobs in the `jobs` table. New Laravel skeletons ship its migration; an older project creates it with `php artisan make:queue-table` and migrates. The cache store must support locks (file, database or redis all do).
:::

## Rollback

Use the `--rollback` flag for rolling back the latest release. See [Rollback and shared storage](#rollback-and-shared-storage) for how uploads are handled.

```bash
php artisan deploy --rollback
```

## Open server target with SSH

Use the `evolve:ssh` command to open a new SSH connection to your configured server. The starting working directory is your configured deployment target.

```bash
php artisan evolve:ssh
php artisan evolve:ssh staging
```

## Inspecting a remote environment

Read-only commands that answer the usual "what is the server doing" questions in one SSH round trip, without logging in:

| Command | Shows |
|---|---|
| `evolve:remote:status {environment}` | the health of the live release: current release, PHP, queue and scheduler state |
| `evolve:releases {environment}` | the release directories on the server and which one `current` points to |
| `evolve:logs {environment}` | the tail of the application log of the current release |
| `evolve:remote:cat {environment} {path}` | one file from the current release, for example a config file or the `.htaccess` |
| `evolve:artisan {environment} {command}` | an allowlisted read-only artisan command on the server, such as `about` or `route:list` |
| `evolve:env:show {environment}` | the server `.env` with secrets masked, see [Environments](/getting-started/environments) |

Anything that writes goes through `deploy`, `evolve:env:setup`, `evolve:db:copy` or the Envoy tasks below, each with the confirmation its environment type demands.

## Envoy tasks

Evolve ships Laravel Envoy tasks for moving media, databases and `.env` files between your machine and the server. Projects created from the starter already have them. Otherwise create an `Envoy.blade.php` in the project root with one line:

```blade [Envoy.blade.php]
@import('vendor/racerfish/evolve/core/bin/Envoy.blade.php')
```

Run them with `php vendor/bin/envoy run <task>` (an alias such as `alias envoy="php vendor/bin/envoy"` helps).

| Task | What it does |
|---|---|
| `media:down` | Download all media from the server |
| `media:up` | Upload all local media to the server |
| `database:down` | Download the database from the server |
| `database:up` | Upload the local database to the server |
| `env:down` | Download the `.env` file from the server |
| `env:up` | Upload the local `.env.[server]-[target]` file to the server |
| `server:env` | Show the current `.env` file on the server |
| `server:migrate` | Run migrations on the server with `--force` |
| `server:clean` | Clear and rebuild all caches and compiled files on the server |
| `server:diff --file=path/to/file` | Compare a local file to the file on the server |

`database:down` and `database:up` replace the database on the receiving side, so the local database often holds the only copy of live data. Treat it accordingly. Both tasks forget the settings cache on the receiving side since 2.16, see [Settings Store](/panel/settings-store).
