Skip to main content

Watching an App Platform static site redeploy

Updated 7 min readMarkdown

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.
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.

No comments yet. Be the first to comment.