# Watching an App Platform static site redeploy

I deployed a free static site on DigitalOcean App Platform and polled it twice a second while it redeployed, rolled back and failed a build. New files went live a few seconds before the API reported the deploy as active, and no request ever failed. Two details surprised me: a rollback pins the app, and a failed build still replaces the app spec.

## Why I wanted to look

The response headers on an App Platform static site say `cache-control: public,max-age=10,s-maxage=86400`. The `s-maxage` part tells the CDN in front of it (Cloudflare) that it may keep a copy for a day. My first reaction was that a redeploy could leave old files at the edge for hours. I wanted to see what visitors actually get during a deploy, and not trust the dashboard's progress bar for it.

## How did I set it up?

- One static site in the Frankfurt region, on the free tier (three static sites are free, and I had none).
- The source was DigitalOcean's public `sample-html` repository, so I didn't need a repo of my own.
- A custom build command copied the two files into `out/` and wrote the build time in nanoseconds into `stamp.txt`. Every build therefore produces a different file, and the file says which build is being served.

```yaml
static_sites:
- name: site
  git:
    repo_clone_url: https://github.com/digitalocean/sample-html.git
    branch: main
  build_command: mkdir -p out && cp index.html color.gif out/ && date -u +%s%N > out/stamp.txt
  output_dir: out
  catchall_document: index.html
```

From my laptop a Python script ran three loops. One fetched `/stamp.txt` every half second. One fetched it every half second with a random query string, so that the CDN cache could not answer. The third asked the DigitalOcean API for the deployment phases every two seconds. Each fetch logged the status, the body, `cf-cache-status` and `age`. The `cf-ray` header ended in `SOF`, so every request went to the Cloudflare edge in Sofia.

## How long does a deploy take?

The first deploy went from create to `ACTIVE` in about 67 seconds. For the timed redeploy I forced a rebuild and counted from the moment I sent the request:

| Event | Seconds after trigger |
|---|---|
| `PENDING_BUILD` | 2.7 |
| `BUILDING` | 7.2 |
| `DEPLOYING`, old deployment `SUPERSEDED` | 36.7 |
| New stamp served (cache-busted URL) | 39.4 |
| New stamp served (plain URL, `MISS`) | 40.0 |
| API reports `ACTIVE` | 43.3 |

344 requests during that window, all `200`. The old stamp was served right up to the switch, the new one right after it, and it never flipped back.

## Does the day-long CDN cache serve old files after a deploy?

Not here. Before the redeploy, the plain URL was a cache `HIT` with an `age` of 49 seconds and climbing. The first plain request that got the new stamp was a `MISS`. So the deploy purged the cached copy, and the day-long `s-maxage` only matters between deploys. The `max-age=10` still applies to browsers, so a visitor could keep the old file for up to ten seconds.

The new content also appeared about four seconds before the API said `ACTIVE`. My phase polling ran every two seconds, so the gap could be anywhere from two to six seconds. Either way, if a pipeline waits for `ACTIVE` before running smoke tests, the new version is already live by then.

## What does a rollback do?

I rolled back to the first deployment through the API, after checking it with the `rollback/validate` endpoint. It created a new deployment with the cause "rollback to previous deployment", and it did not build anything:

| Event | Seconds after trigger |
|---|---|
| `PENDING_BUILD` | 4.5 |
| Old stamp served again | 10.5 |
| API reports `ACTIVE` | 10.8 |

The body was the first build's exact nanosecond timestamp, and `Last-Modified` was the first build's time, 15:23:58. App Platform keeps the built output and puts it back. That took about 11 seconds, against about 43 for a rebuild.

Then I tried to deploy again and got this:

```
400 App is pinned
```

A rollback pins the app to the rolled-back deployment. Nothing new deploys until you either commit the rollback (`POST /v2/apps/{id}/rollback/commit`) or revert it. That makes sense for an incident, since you don't want the next push to undo your fix. But if you roll back from a script and forget the commit, your next pushes won't deploy.

## What happens when a build fails?

I changed the build command to `exit 1` with `doctl apps update`. The build ran for about 25 seconds and the deployment went to `ERROR`. All 218 requests during and after that returned `200` with the old stamp, so the live site was never touched.

The app's spec was. Afterwards `doctl apps get` showed the failing build command as the app's spec, while the active deployment still carried the old one. The live site and the spec it supposedly runs no longer matched. The next deploy of any kind would use the broken command. If you treat the app spec as the source of truth, after a failed deploy it is the wrong one until you fix it.

## A missing file returns your homepage

I set `catchall_document: index.html`, as I would for a single-page app. Then I requested a path that does not exist, `/no-such.txt`. It came back `200` with `content-type: text/html` and the homepage as the body. The only sign of the miss was a header, `x-do-orig-status: 404`. The second request was a cache `HIT`.

For a single-page app this is the point of the setting. It also means a mistyped script or image path doesn't fail visibly: the browser gets HTML with a `200` and fails somewhere else. If you need real 404s for assets, use `error_document` instead, or check that header in your tests.

## What else is in the headers?

- HTML is served with Brotli (`content-encoding: br`). The 499 KB GIF is sent as is.
- `alt-svc: h3=":443"` advertises HTTP/3. My curl build couldn't test it.
- Every file gets the same `cache-control`, including `stamp.txt`. There's no per-file setting for static sites that I found.

## What I got wrong

I expected to measure how long old files lingered at the edge after a deploy, and there was nothing to measure. The day-long `s-maxage` looked like a problem and wasn't one, because the deploy purges it.

I set `catchall_document` out of habit and only noticed what it does to missing assets because I happened to test a `.txt` path. I didn't run a second site with `error_document`, so I can't show the alternative's behaviour from my own logs.

My probes only started once the app had a default URL, so for the first deploy I can't say when content became reachable, only when the API said `ACTIVE`.

## Caveats

One site, one region, one Cloudflare edge (Sofia), one afternoon. Another edge may switch over a few hundred milliseconds earlier or later than the one I watched. I tested static sites only. Services and workers deploy differently and may not switch as cleanly. The whole test cost nothing, and I deleted the app when it was done.

## What I would take from it

- Redeploys of a static site switched cleanly at the edge I watched: no errors, no mixed versions, no stale cache.
- Don't wait for `ACTIVE` to mean "about to go live". It was already live.
- Rollbacks are fast because they don't rebuild, and they pin the app. Commit or revert them.
- After a failed deploy, fix the spec, because the app already holds the broken one.
- With a catchall document, a missing asset is a `200`. Test for `x-do-orig-status`.

