Releasing and Rolling Back

Release by flipping a pointer at a new directory, keep the previous build's chunks, and test the rollback before you need it.

Syntaxwindow.addEventListener('vite:preloadError', (event) => { event.preventDefault() })

A release is atomic when the switch from the old build to the new one is a single operation that no request can land in the middle of. Copying files over a live directory is not that. A browser that asks for index.html halfway through the copy gets HTML naming chunks that have not been uploaded yet, and there is no retry that fixes it.

The shape that works

Upload the build into a new directory named for the release. Nothing is serving it, so the copy can take as long as it needs and can fail harmlessly. Then move a pointer at that directory in one step: a symlink rename, an nginx root, a CDN origin path, a container image tag. Rolling back is the same move in reverse, which is the reason you keep the previous release rather than deleting it.

The user who already loaded the old JavaScript

Nothing happens to them at first. The page they are on keeps working, because its chunks are already in memory. The failure comes later, when they navigate to a lazily imported route and the browser requests a hashed file the new release does not contain. Vite fires a vite:preloadError event on window for this. The original error is on event.payload, and calling preventDefault() stops it being thrown. Handle it by offering a reload.

What trips people up

Keeping only the newest release is the common mistake, and it turns every deploy into a broken navigation for anyone mid-session. Carry the previous release's assets/ forward. Then rehearse the rollback on a quiet afternoon, because an untested rollback is not a rollback.

Example

Example · vue
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'

const stale = ref(false)

// VitePreloadErrorEvent is declared globally by vite/client, which the
// scaffolded env.d.ts already references. payload holds the original error.
function onPreloadError(event: VitePreloadErrorEvent): void {
  event.preventDefault()
  console.warn('Chunk missing after a release', event.payload.message)
  stale.value = true
}

function reload(): void {
  window.location.reload()
}

onMounted(() => {
  window.addEventListener('vite:preloadError', onPreloadError)
})

onUnmounted(() => {
  window.removeEventListener('vite:preloadError', onPreloadError)
})
</script>

<template>
  <div v-if="stale" class="stale-build" role="alert">
    <p>A new version was released. Reload to continue.</p>
    <button type="button" @click="reload">Reload</button>
  </div>
</template>

<style scoped>
.stale-build {
  position: fixed;
  right: 1rem;
  bottom: 1rem;
  max-width: 20rem;
  padding: 1rem;
  background: #111827;
  color: #f9fafb;
}

.stale-build button {
  margin-top: 0.5rem;
}
</style>

When to use it

  • Deploying to a VPS without a window where the site serves half a build.
  • Reverting a bad release in under a minute without rebuilding anything.
  • Keeping users mid-session from hitting a blank page when they navigate after a deploy.

More examples

scripts/release.sh

The whole release in one script, with nginx pointed at `/srv/web/current`. The two lines that matter are the asset carry-forward and the `mv -T`: `ln -sfn` unlinks before it links, leaving a window where `current` does not exist, while `mv -T` replaces the symlink in a single rename. Both `mv -T` and `xargs -r` are GNU coreutils, so this runs on Linux and needs adjusting on macOS. A rollback is `ln -sfn` and `mv -T` against the previous release directory, which is still on disk.

Example · bash
#!/usr/bin/env bash
# scripts/release.sh -- atomic release onto a host you own
set -euo pipefail

ROOT=/srv/web
RELEASE="$(git rev-parse --short HEAD)"

npm ci
npm run build

# Nothing serves this path yet, so a slow or failed copy hurts no one.
rsync -a --delete dist/ "$ROOT/releases/$RELEASE/"

# Carry the live release's hashed chunks forward. -n keeps the new file when a
# name appears in both, so unchanged files stay byte-identical.
if [ -L "$ROOT/current" ]; then
  cp -rn "$(readlink -f "$ROOT/current")/assets/." \
    "$ROOT/releases/$RELEASE/assets/"
fi

# The flip. ln -sfn is not atomic on its own; mv -T over the old symlink is.
ln -sfn "$ROOT/releases/$RELEASE" "$ROOT/current.tmp"
mv -Tf "$ROOT/current.tmp" "$ROOT/current"

# Keep the five most recent releases so a rollback has somewhere to go.
ls -1dt "$ROOT"/releases/* | tail -n +6 | xargs -r rm -rf

nginx pointed at the symlink

The server side of the same release. Pointing `root` at the symlink rather than at a release directory is what lets the script own the switch, with nginx untouched. The `open_file_cache` caveat is worth knowing before you turn that directive on for performance: with it enabled, a release needs an `nginx -s reload` to take effect, which reintroduces a step the symlink was meant to remove.

Example · nginx
# /etc/nginx/conf.d/web.conf
server {
  listen 80;
  server_name app.example.com;

  # The symlink the release script swaps. nginx resolves it per request, so
  # a flip takes effect with no reload -- unless open_file_cache is enabled,
  # in which case cached descriptors keep serving the old release.
  root /srv/web/current;
  index index.html;

  location /assets/ {
    add_header Cache-Control "public, max-age=31536000, immutable" always;
    try_files $uri =404;
  }

  location = /index.html {
    add_header Cache-Control "no-cache" always;
  }

  location / {
    try_files $uri $uri/ /index.html;
  }
}

Rolling back a container deployment

On Kubernetes the atomic unit is the image tag, so the rollback is already built: `rollout undo` reverts to the previous revision with the same readiness gating as the roll forward. Two things still need care. Tag every image with the release rather than `latest`, or there is no previous revision to go back to. And make the previous image's assets reachable, because `undo` restores the old JavaScript for new visitors while anyone who loaded the new index.html is now the one with the wrong chunks.

Example · bash
# Roll forward, and let the deploy job fail if it does not become ready.
kubectl set image deployment/web web=registry.example.com/web:1.4.2
kubectl rollout status deployment/web --timeout=120s

# Smoke-test the thing that is now serving, not the thing you built.
curl -fsS -o /dev/null https://app.example.com/index.html

# The rollback: one command, back to the previous recorded revision.
kubectl rollout undo deployment/web
kubectl rollout status deployment/web --timeout=120s

# Which revisions exist, and what image each one holds.
kubectl rollout history deployment/web --revision=3

Discussion

  • Be the first to comment on this lesson.