Skip to main content
Have you ever stared at an Upsun configuration file wondering whether your migration belongs in the deploy hook or the post_deploy hook? You’re not alone. Upsun gives you 6 places to run a command during a deployment: a build hook, pre_start, start, post_start, a deploy hook, and a post_deploy hook. Each one has its own page in the docs, and each page is good. The part the docs can’t answer for you is the one you actually care about: which one do you reach for, for the task in front of you? That answer depends on context. It depends on when the command needs to run, whether it needs a database, whether it should block traffic, and even on the kind of mount you write to. None of that jumps out when you read the reference pages one at a time. So this post walks through each command and hook, then shows how they fit together with a few real examples.

Two phases: build, then deploy

Every deployment has 2 parts: a build, then a deploy. They run in different worlds, and that split explains most of the rest. The build runs in isolation. No database, no services, no other containers. Upsun checks out your code, installs dependencies, runs the build flavor for your language, runs your build hook, then freezes the result into a read-only image. The deploy takes that frozen image and makes it live. Now services are reachable, mounts are attached, and the file system is read-only apart from those mounts. If you use zero-downtime deployments, Upsun starts the new container next to the old one and removes the old one only once the new one is ready, so live traffic never drops. Keep that one line in mind: the build is isolated and reusable, the deploy is live and connected. Where a command belongs almost always comes down to which side of that line it sits on.

The build hook has nothing to do with runtime

The build hook, your build dependencies, and the build flavor all affect build time, not runtime. They run while your app is being assembled, before any database exists. Good fits: compiling assets, installing packages, generating code, bundling a frontend. Anything that turns your source into the artifact you want to ship and needs nothing more than your code plus write access to disk. The build hook is also the one hook that can stop a deploy. If it fails, the build is aborted and nothing ships. The other hooks don’t behave that way: if they fail, the deploy still happens. Add set -e at the top so the hook fails on the first failing command instead of only the last.
applications:
  app:
    type: "nodejs:22"
    source:
      root: "/"
    hooks:
      build: |
        set -e
        npm ci
        npm run build
One more thing the build buys you: the result is tied to your commit and reused. If a container has no changes, Upsun skips its build entirely. The build hook is the cheapest place to do expensive, deterministic work, because you pay for it once per commit, not once per deploy.

pre_start, start, and post_start run every time your app starts

These 3 commands wrap your app process, in order: pre_start, then start, then post_start. Here’s the part that’s easy to miss: unlike the hooks, which run once per deploy, this trio runs every time the start process starts. And it starts again every time it’s restarted, after a deploy, after host maintenance, and any time start exits and the supervisor brings it back. You configure them under web.commands.

start: the supervised one

start launches your app and keeps it running. Upsun supervises it: if it ever exits, it gets restarted right away. That’s the detail that matters. start is not a script that runs once and finishes, it’s a long-running process expected to stay up. Two things follow from that. Don’t background it with &: the supervisor reads a terminating command as “start another copy”, and you land in a loop until the container crashes. And start has to be that long-running process, so setup work that needs to run and then finish belongs in pre_start instead. On most languages you write a start command. On PHP you can leave it out, and Upsun runs PHP-FPM for you.

pre_start: setup that runs and then finishes

pre_start runs right before start, every time the app starts. The difference from start is semantic, not about how often each runs: pre_start is a command you expect to run to completion and exit, while start is the supervised process that stays up. So pre_start is where setup that has to finish before your app launches goes. Take a PHP app. You want the default PHP-FPM start command, but you also want to clear and warm the cache first, so OPcache loads the right code from the first request. You can’t do that inside start without replacing the default with a wrapper script. pre_start lets you keep the default start and still run the prep beforehand. Same idea for a Python app that generates templates before serving: generate in pre_start, run the app in start.
applications:
  app:
    type: "php:8.3"
    source:
      root: "/"
    web:
      commands:
        pre_start: "php bin/console cache:warmup"
        # start is omitted: PHP-FPM is the default

post_start: wait until the app is actually ready

post_start runs after start, before the container is added to the router. Its job is to make the orchestration wait until your app is genuinely ready to serve, not merely started. This matters most during zero-downtime deployments and horizontal scaling. When you add an instance, you don’t want traffic routed to it before it can answer. A post_start that polls localhost until the app responds holds the handover until then, so the old container stays up and requests aren’t sent too early. The payoff is no dropped requests while scaling. Whether you need it depends on your stack. A start command returns almost instantly: the process is up, but the app inside may still be warming. PHP serves on the first request, so it rarely needs post_start. Python apps tend to start fast enough to skip it. Java apps are the classic case where it earns its place: the JVM takes a moment to come up, and post_start gives it that moment before traffic arrives.
applications:
  app:
    type: "java:21"
    source:
      root: "/"
    web:
      commands:
        start: "java -jar target/app.jar"
        post_start: |
          curl -sS --retry 20 --retry-delay 1 --retry-connrefused localhost -o /dev/null

The deploy hook runs once, before traffic

The deploy hook runs after the container has started but before it accepts requests. It runs once, on a single web instance, with services reachable and the file system read-only apart from mounts. While it runs, incoming requests are held. That makes it the right home for work that has to happen exactly once and exclusively, against code that matches what’s on disk. Database schema migrations are the textbook case. You don’t want live requests hitting the database mid-migration unless your app was built specifically for that, and if it was, you already know to go for it. For everyone else, the deploy hook is where migrations belong. Keep it short. Held requests can wait out a few seconds, but a long deploy hook turns into dropped connections. Anything that can run safely while requests flow should move to post_deploy instead.

The post_deploy hook runs once, after traffic

The post_deploy hook is the deploy hook’s mirror image: same once-only, services-available behavior, but it runs after the app is open to the world and alongside live traffic. Reach for it when the work doesn’t need exclusive access and can happen in a lazy fashion: clearing a CDN cache, warming caches that tolerate concurrent reads, kicking off a content import. It comes with one rule. post_deploy is the only hook that runs on a redeploy with no code changes, so its scripts need to be idempotent, or safe to run more than once. Write them as if they will run twice, because eventually they will.

Your mount type changes the answer

Here’s the part that catches people out, and it’s worth slowing down for. The kind of mount you write to influences which command you should pick. A storage mount is shared across all instances of your app over the network. An instance mount is local to each instance: with 3 instances behind horizontal scaling, each gets its own local disk with nothing shared between them. That distinction decides where a file-generating command belongs:
  • If you write to an instance mount, a deploy hook only populates one node, because it runs on a single instance. Files generated there never reach the others. To put per-instance files on every node, generate them in pre_start or post_start, which run on each instance.
  • If you write to a storage mount, the deploy or post_deploy hook works, because every instance sees the same shared directory. Pick between the two based on whether the work should block traffic.
  • If you rely on /tmp or other ephemeral storage, plan for it to be empty when the container starts again. The deploy and post_deploy hooks run once per deploy, but a container can restart on its own without a new deploy, and only pre_start, start, and post_start run on those restarts. So anything you need in ephemeral storage has to be regenerated in pre_start, or it won’t be there.
Disk performance pulls in the same direction. Local instance disks are faster than the shared network storage. If you generate something heavy, say an e-commerce catalog rendered for a dozen languages across a million products, doing it once on a shared mount in a deploy hook can drag out the deployment. Generating it on the fast local disk in pre_start can be the better trade. It isn’t micro-optimization, it’s infrastructure optimization, and at that scale it adds up.

A quick decision guide

When you’re not sure, these questions usually settle it:
If you need to…Reach for
Compile, bundle, or install, with no servicesbuild hook
Set up something per instance, on every startpre_start
Run and keep your app process alivestart
Wait until the app is ready before routing trafficpost_start
Run something once, before traffic, with exclusive accessdeploy hook
Run something once, after traffic, that’s safe to repeatpost_deploy
There’s no single right configuration. Each application makes its own calls based on what it needs. If you run Symfony, its Upsun integration already wires up sensible defaults for all of these, so you may rarely touch them by hand. For everything else, the questions above are the ones to ask. Once you can map a task to the moment it should run, the configuration writes itself. That’s the part the reference pages leave to you, and it’s the part worth getting right. Want to put this into practice? Create a free Upsun account and read the full build and deploy hooks guide to see each one in context.
Last modified on June 4, 2026