> ## Documentation Index
> Fetch the complete documentation index at: https://docs.shipways.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Deployments

> How a release is built, checked, made live, and put back.

Shipways deploys by building a new release beside the running one and then
moving a symlink. **Nothing the site is currently serving is touched until the
new release has been built and checked**, so a deploy that fails leaves the
previous one running.

## The layout on disk

```
/home/<deploy user>/<domain>/
├── current -> releases/20260802143000
├── releases/
│   ├── 20260802143000
│   └── 20260801091200
└── shared/
    ├── .env
    └── storage/
```

Nginx serves through `current`, never a release by name. That is what makes
going live a rename rather than a rewrite of the vhost and a reload of the web
server on every deploy.

## The steps

<Steps>
  <Step title="Fetch the code">
    A fresh checkout of the branch into a new release directory.
  </Step>

  <Step title="Record the commit">
    Read from the release rather than from whatever triggered the deploy, so
    the history reflects the code that actually ran even when the branch moved
    in between.
  </Step>

  <Step title="Link the shared files">
    `storage` and `.env` are replaced with links into `shared`, along with any
    [shared paths](/sites/deploy-script#shared-between-releases) you named.
  </Step>

  <Step title="Prepare the environment file">
    On the first deploy only. See [environment](/sites/environment).
  </Step>

  <Step title="Check the pending migrations">
    Read, never run. See [migration review](#migration-review).
  </Step>

  <Step title="Run the deploy script">
    Yours. See [the deploy script](/sites/deploy-script).
  </Step>

  <Step title="Check the release can boot">
    The autoloader is required and, for a Laravel application, the framework is
    booted in full. A missing extension, an unreadable `.env`, a broken service
    provider or a package left out of the install all fail here — while the
    site is still serving the previous release and nothing is at risk.
  </Step>

  <Step title="Make the release live">
    The symlink is moved, atomically, and FPM is reloaded so PHP stops serving
    the old release out of its resolved-path cache.
  </Step>

  <Step title="Check the site responds">
    A real HTTP request. See below.
  </Step>

  <Step title="Prune old releases">
    Down to the number you chose to keep.
  </Step>
</Steps>

## The two checks

They sit either side of the switch on purpose.

The **boot check** catches a release that cannot start, with **no downtime at
all** — the old release is still serving.

The **HTTP check** catches a release that boots on the command line and still
fails a real request. It is sent to loopback with the site's `Host` header, so
it works before DNS points anywhere. Anything below 500 counts as alive: a 404
or a redirect means the application is running and answering, which is what is
being asked.

If the HTTP check fails, **the previous release goes straight back** rather
than waiting for somebody to notice the site is down. The deployment is
recorded as rolled back, which says more than a plain failure.

If it fails and there is no earlier release to put back, the deploy fails and
the site stays as it is.

A site under [maintenance](/sites/maintenance) answers 503 to everybody. The
check carries the bypass header so that a deploy into a site that is
deliberately down does not roll itself back for that reason alone.

## Migration review

Neither check above helps with a migration. The deploy script runs
`php artisan migrate` on the way past, and a single `ALTER TABLE` on a large
table can hold a lock for minutes: the release boots, the HTTP check passes, and
the site is down anyway. Putting the previous release back would not help
either — the lock is in the database, not in the code.

So before the deploy script runs, Shipways reads the migrations the release is
about to run and reports the ones doing something known to lock:

| Reported                      | Why it holds a lock                                                                                                                                                 |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| An index on an existing table | Built over every existing row. PostgreSQL locks the table for the whole build unless the index is created concurrently, which Laravel's schema builder does not do. |
| A column added with a default | Instant on MySQL 8 and PostgreSQL 11 and later. Earlier versions rewrite every row.                                                                                 |
| A changed column              | Rewrites the table on MySQL, and on PostgreSQL for anything but a widening within the same type.                                                                    |
| A dropped column              | MySQL rebuilds the table. On both, the column is gone the moment it runs, so the release still serving traffic must already have stopped selecting it.              |
| A foreign key added           | Every existing row is checked against the referenced table, with both tables held.                                                                                  |
| Raw `DB::statement`           | Not read at all, and reported as unread rather than passed over.                                                                                                    |

Beside each one is the size of the table **at the moment of the deploy**, asked
of the engine while the release is being checked. The same statement is nothing
on a thousand rows and an outage on fifty million, and the size is what tells
them apart.

<Note>
  There is no estimate of how long a lock will last, and there will not be. It
  depends on the engine, its version, how much of the table is already in the
  buffer pool and what else is hitting it — a figure invented from outside the
  machine would be planned around, be badly wrong once, and take the
  credibility of the rest with it.
</Note>

### What it does not tell you

It reads the migration files as text and looks for a known list of patterns. It
does not run them, and `migrate --pretend` is deliberately not used: pretend
still executes the migration's own PHP.

That means it will miss things. A schema change written in raw SQL, built from
a macro, or generated at runtime is not read, and is reported as unread. A
report with nothing in it means nothing was recognised — **it is never a
statement that a migration is without risk**.

It also reports statements inside conditionals as though they will run, because
deciding otherwise would mean evaluating the condition.

### Holding a deploy

Off by default. Every deploy reports what it found and carries on.

Turn on *Hold for migration review* on a site and a deploy **you** started stops
before its migrations run when something is raised, leaving the previous release
serving. Read the report on the deployment page and press **Deploy anyway** to
go ahead; that starts a fresh deploy carrying your answer about that commit, so
if the branch moved in the meantime it is held again.

A push, a deploy hook and a rollback are **never** held, whatever the setting.
A pipeline that silently stops is a release everybody thinks went out and did
not, which is worse than the lock it was warning about.

A finding is raised, rather than only listed, when the table has more than
250,000 rows — or when its size could not be measured, because a table nobody
has a number for is not a small one. Set `SHIPWAYS_LARGE_TABLE_ROWS` to move
that line.

## Triggering a deploy

**Manually**, from the site.

**On push**, from the *Deploy on push* setting. Shipways registers a webhook
with your [source control account](/source-control). If it could not register
one — no connected account, or a token without the access — the panel gives you
the address to add yourself rather than pretending it worked.

**From CI**, with a [deploy hook](#the-deploy-hook).

**A rollback** puts an earlier release back. Only releases still on disk are
offered.

## The deploy key

Each site gets its own SSH key pair. The public half goes on the repository as
a **read-only deploy key**; it reaches that one repository and nothing else.

If you have connected a source control account, Shipways installs it for you.
Otherwise copy it and add it yourself.

**Rotate** issues a new pair. The old one stops working immediately, so a key
that leaked is dealt with in one action — but a key you installed by hand needs
installing again.

## The deploy hook

An address CI can `POST` to in order to deploy the site's branch.

Nothing signs it, so **the address is the credential**. Anyone holding it can
deploy this site. Keep it where you keep your other secrets, and replace it if
it ends up somewhere it should not.

## Releases and rollbacks

**Releases to keep** decides how many stay on disk after a deploy. Each one is
another copy of the application, `vendor` and built assets included, so this is
a disk-space decision as much as a safety one.

Pruned releases stop being offered for rollback, because the directory is no
longer there.

The holding page a site starts life serving is never pruned. It is the fallback
that is always available however many deployments come and go.

## When a deploy fails

The step that failed keeps its output. The common ones:

**No `vendor/autoload.php`** at the boot check — the deploy script did not
install dependencies, or the [root directory](/sites/creating#root-and-web-directory)
is pointing somewhere without a `composer.json`.

**The boot check fails on configuration** — usually a value missing from the
[environment file](/sites/environment).

**The HTTP check fails but the boot check passed** — the application starts and
cannot serve. Assets not built is the most common cause; check the site's
[error log](/servers/logs).
