A self-updating pkg: install the latest app version from Azure Blob in a postinstall
Stop re-packaging an app on every release. Build a payload-free pkg once whose postinstall downloads the current installer from Azure Blob Storage — replacing one blob ships a new version to everyone.
Re-packaging an app every time the vendor ships an update gets old fast: download the new version, build the pkg, upload it to Jamf, fix the scope, test. Do that across a dozen apps and you spend real time pushing bytes around.
Here’s the pattern I use instead. I build one pkg that contains no application at all — just a postinstall script. When that pkg runs on a Mac, the script reaches out to Azure Blob Storage, pulls down the current installer, and runs it. The pkg I upload to Jamf never changes. To ship a new version of the app, I overwrite a single blob in the container. Every deployment after that gets the latest build, with no re-packaging and no touching Jamf.
The shape of the trick
Two pieces:
- A payload-free pkg — a package with zero files, whose only job is to run a script.
- A
postinstallscript that downloads the real installer from blob storage and installs it.
That’s it. The “latest version” lives in the bucket, not in the package.
Building the payload-free pkg
Put the script in a scripts/ folder named exactly postinstall, make it executable, and hand the folder to pkgbuild with --nopayload:
mkdir -p scripts
# (write the postinstall below into scripts/postinstall first)
chmod +x scripts/postinstall
pkgbuild --nopayload \
--scripts scripts \
--identifier com.yourorg.acme-bootstrap \
--version 1.0 \
Acme-Bootstrap.pkg
--nopayload tells pkgbuild there are no files to lay down — the package exists only to run the scripts in scripts/. The postinstall runs as root at install time, which is exactly what you want for dropping an app into /Applications.
If you deploy this outside Jamf as well, sign it so Gatekeeper and installer trust it:
productsign --sign "Developer ID Installer: Your Org (TEAMID)" \
Acme-Bootstrap.pkg Acme-Bootstrap-signed.pkg
Inside Jamf, signing isn’t strictly required — the management framework installs it directly — but it’s good hygiene.
The postinstall script
#!/bin/bash
# postinstall — download the current installer from Azure Blob and install it.
# Ships inside a payload-free "bootstrap" pkg.
# Replace the blob in Azure = ship a new version. This pkg never changes.
set -euo pipefail
# --- config -----------------------------------------------------------------
# Read-only, container-scoped, expiring SAS. It only grants pulling an
# installer we'd hand out anyway — low sensitivity, but keep it read-only.
BLOB_URL="https://YOURACCOUNT.blob.core.windows.net/installers/Acme-latest.pkg"
SAS="?sv=2024-11-04&ss=b&srt=o&sp=r&se=2027-01-01T00:00:00Z&sig=REDACTED"
# ----------------------------------------------------------------------------
workdir="$(mktemp -d)"
trap 'rm -rf "$workdir"' EXIT
pkg="$workdir/installer.pkg"
echo "Downloading the current installer…"
if ! curl -fsSL "${BLOB_URL}${SAS}" -o "$pkg"; then
echo "ERROR: download failed" >&2
exit 1
fi
# A failed/expired SAS returns an XML error page, not a pkg. Catch that before
# handing garbage to installer.
if ! file "$pkg" | grep -qi 'xar archive'; then
echo "ERROR: downloaded file is not a valid pkg (check the SAS/URL)" >&2
exit 1
fi
echo "Installing…"
installer -pkg "$pkg" -target /
exit 0
A few deliberate choices in there:
mktemp -d+ atrapso the download lands in a unique temp dir and gets cleaned up no matter how the script exits.curl -fsSL—-ffails the command on an HTTP error instead of happily saving the error body; quote the whole URL because the SAS string is full of&and=.- The
file … xar archivecheck. This is the one that saves you a support ticket: when a SAS expires or the URL is wrong, Azure hands back an XML error document. Without the check you’d pass that toinstallerand get a confusing failure. The check turns it into a clear log line.
Hosting the installer in Azure Blob
Create a private container (don’t make it public unless you mean to), then overwrite one fixed blob name on every release:
az storage blob upload \
--account-name YOURACCOUNT \
--container-name installers \
--name Acme-latest.pkg \
--file ./Acme-3.4.1.pkg \
--overwrite
The Macs always pull Acme-latest.pkg; you decide what that name points at. Shipping 3.4.2 next month is one upload --overwrite away.
The SAS token
Generate a read-only, container-scoped SAS with an expiry — never an account key:
az storage container generate-sas \
--account-name YOURACCOUNT \
--name installers \
--permissions r \
--expiry 2027-01-01 \
--auth-mode login --as-user \
--output tsv
Yes, that token ships inside the pkg. I’m comfortable with that because it grants exactly one thing — reading an installer I’d otherwise hand to anyone on the fleet — and nothing else: no write, no listing the account, no other container. To rotate it without rebuilding the pkg, back the SAS with a stored access policy and revoke the policy server-side. Otherwise, rotating means issuing a new SAS and rebuilding the bootstrap pkg once.
Deploying it
Upload Acme-Bootstrap.pkg to Jamf once and scope a policy to it however you like — at enrollment, in Self Service, on a schedule. From then on the package is frozen. The only thing that ever changes is the blob.
Not on Azure?
The pattern is storage-agnostic — only the URL and the auth differ:
- AWS S3 — host the installer in a bucket and download via a presigned URL (or a public object). Same
curl … | installershape. - Any HTTPS host — Backblaze B2, Cloudflare R2, even a plain web server. If
curlcan reach it, the script doesn’t care.
And if you outgrow a single fixed name — say you want version history and rollback — upload versioned files (Acme-3.4.1.pkg) plus a tiny Acme.json manifest that names the current one. The script reads the manifest first, then downloads what it points to. More moving parts, but you keep every build.
Takeaway
Packaging doesn’t have to mean re-packaging. Build the wrapper once — payload-free pkg, a postinstall that pulls from blob storage — and your “update the app” workflow shrinks to a single az storage blob upload --overwrite. The pkg in Jamf becomes a stable pointer, and the latest version lives where it’s easy to replace.
Subscribe to gen/os
New write-ups on Apple device management — Jamf, Intune, Mosyle, scripting, and automation. Straight to your inbox, no spam, unsubscribe anytime.
Found this useful? Subscribe via RSS for new posts, orget in touch if I got something wrong.
Comments