SWUpdate Delta OTA for Linux
A SWUpdate-based delta update involves two files, which the service uses to identify only the parts of the rootfs that actually changed. The device downloads only the parts of the image it does not already have, and reuses the rest from the active partition.
This guide covers how to produce the two files, how to upload them, and what
your sw-description has to contain. It assumes you already have
OTA with SWUpdate with suricatta + hawkbit working with a
plain .swu payload.
The term 'delta' as it relates to Linux OTA differs from how delta OTA works on MCUs - specifically, it is a full release, without a source and target version to build from. Any version can update to any newer version, and there is no version-specific diff compared ahead of time - it is calculated on the fly on the device. Hence, Linux versions with this additional metadata will not be considered as "delta releases". It is simply a full release with additional metadata which the device can use to determine what parts of the image to download.
Passing --delta-from or --delta-to with a zchunk payload is rejected by the
CLI.
How a zchunk delta update works
A zchunk file is an archive split into independently decompressible chunks, with a manifest of per-chunk checksums in a header at the front of the file. Chunk boundaries come from a rolling hash, which improves the chunk reuse rate as compared to fixed-size blocks.
| File | Contains | Size | How the device gets it |
|---|---|---|---|
<image>.swu | sw-description plus the detached zchunk header | A few hundred KB | Downloaded in full |
<image>.ext4.zck | The chunk index and the chunk data for the whole image | A full firmware image | Fetched by HTTP Range |
An update then runs like this:
- The device polls Memfault over the hawkBit DDI API. The deployment lists both artifacts, each with its own signed CDN URL.
- SWUpdate's suricatta daemon classifies each artifact by file extension. The
.swuis installed. The.zckis marked as skipped and its URL is handed to the installer instead of being downloaded. - The installer unpacks the
.swu, readssw-description, and gives the detached header to thedeltahandler. - The
deltahandler reads the source partition end to end and builds an index of its chunks, hashing the uncompressed data. - The handler compares the source index against the target header and marks each target chunk as present or missing.
- Present chunks are copied from the source partition. Missing chunks are
fetched from the
.zckwith HTTP range requests, and each one is verified against its digest in the header. - The reassembled image is piped to a chained handler,
raw, which writes it to the inactive partition. - suricatta reports the result back to Memfault.
Requirements
- SWUpdate 2026.05 or later. The
dynamicURL mechanism that lets thedeltahandler resolve the.zckURL at install time landed upstream in January 2026 and was first tagged in2026.05. Earlier versions cannot be used with Memfault-signed URLs, because the URL is not known at build time. CONFIG_DELTA=yandCONFIG_ZSTD=yin your SWUpdate configuration.CONFIG_DELTApulls in thedeltahandler and the delta downloader.CONFIG_DISKFORMAT=y, if you usesource-size = "detect". Without it, SWUpdate logsSWUPdate not compiled with DISKFORMAT, skipping size detectionand reads the source partition to its end instead of to the end of the filesystem.- Highly encouraged: the two SWUpdate patches described in
Two patches we ship. Without the coalescing
patch,
max-ranges = 1issues a brand new HTTP request + TLS termination for each missing chunk, even with many consecutive chunks missing. - zchunk 1.5.x on the build host, for
zckandunzck. - Memfault CLI 1.10.0 or later, for
upload-zchunk-ota-payload. - An A/B partition layout, with the inactive partition as the write target and a rootfs partition as the source.
- Encryption off for the
.zckpayload. Thedeltahandler forcesis_encrypted = false- CBC is not usable with arbitrary range downloads, so upstream SWUpdate does not support it.
Building the two files
zck -u is mandatory
Omitting -u costs the entire bandwidth saving and produces no error, no
warning, and no failed update.
Create the .zck from the uncompressed image, with -u:
zck -u --chunk-hash-type sha256 base-image.ext4
# writes base-image.ext4.zck
-u ("add extension in header for uncompressed data") records a digest of each
chunk's uncompressed bytes in the header, alongside the digest of its compressed
bytes.
The device needs those uncompressed digests. Its source is a raw block device,
so the delta handler builds the source index with compression set to none.
When zchunk compares the two indexes, it only compares compressed digests if
both sides used the same compressor.
If -u is missing, neither comparison is possible. zchunk marks every target
chunk as not present, and there is no error and no warning anywhere. The update
succeeds, reuses nothing, and range-fetches the entire .zck. Nothing in
SWUpdate's log or in Memfault says the delta did not take. The only signal is
the byte count that the handler logs before the transfer:
INFO : Total bytes to be reused : 0
INFO : Total bytes to be downloaded : 182863255
Extracting the detached header
Use unzck --header:
unzck --header base-image.ext4.zck
# writes base-image.ext4.zhr
The SWUpdate documentation suggests reading the first header_size bytes of the
.zck with dd. That works only as long as the .zck carries no zstd
dictionary, because the dictionary sits after the header and is needed to
decompress any chunk. unzck --header writes the header plus the compressed
dictionary and marks the result as a detached header. The moment anyone passes
-D to zck, a dd of the header produces a file that cannot reconstruct
anything.
unzck has no output option. It strips .zck from the input name and appends
.zhr, so base-image.ext4.zck becomes base-image.ext4.zhr. Rename it
afterwards if you want a different name. The name of the header file matters
only in that sw-description names it as the image filename; it is not part
of the filename contract.
Then pack the header into the .swu in place of the image. The .swu contains
sw-description and the header, and nothing else for this image.
With Yocto
meta-swupdate ships an
image_types_zchunk class that does
both steps as image conversions, and it already passes -u:
IMAGE_CLASSES:append = " image_types_zchunk"
IMAGE_FSTYPES:append = " ext4.zck ext4.zck.zckheader"
The zck conversion runs zck --output ... -u --chunk-hash-type sha256 over
the .ext4. The zckheader conversion reads the header size with
zck_read_header and dds that many bytes, which is correct for as long as no
dictionary is in use. Both files land in tmp/deploy/images/${MACHINE}.
The Memfault Linux SDK example layer wires this up behind one switch. In
conf/local.conf:
MEMFAULT_DELTA_OTA = "1"
IMAGE_ROOTFS_SIZE = "258048"
IMAGE_OVERHEAD_FACTOR = "1"
IMAGE_ROOTFS_MAXSIZE = "262144"
IMAGE_CLASSES:append = " image_types_zchunk"
IMAGE_FSTYPES:append = " ext4.zck ext4.zck.zckheader"
MEMFAULT_DELTA_OTA = "1" applies a delta.cfg Kconfig fragment
(CONFIG_DELTA, CONFIG_ZSTD), applies the two SWUpdate patches, and stops the
swupdate-delta-image recipe from skipping
itself. Then:
bitbake swupdate-delta-image
produces three files in tmp/deploy/images/${MACHINE}:
| File | What to do with it |
|---|---|
swupdate-delta-image-${MACHINE}.rootfs.swu | Pass as --zchunk-header |
base-image-${MACHINE}.rootfs.ext4.zck | Pass as --zchunk-image |
base-image-${MACHINE}.rootfs.ext4.zck.zckheader | Nothing. It is already inside the .swu |
Do not rebuild the image at the old version after building the delta artifacts.
Yocto re-points the IMAGE_LINK_NAME symlinks on every build, so the .zck
symlink would move to the old chunk data while the .swu still carries the new
header. The install then fails on chunk digests.
Image size and the A/B slot
A delta install writes a plain image into the inactive rootfs slot, so the image has to fit the slot exactly. Pin the slot size for the following reasons:
- An image larger than the slot fails at the end of the transfer with
cannot write N bytes: No space left on device, after the whole download has already run. - Equal size keeps the filesystem geometry identical between the running partition and the target image, which is what keeps chunk reuse high. An image built at a different size shifts data and partially defeats the rolling hash.
In the example above, IMAGE_ROOTFS_MAXSIZE is the slot size, so an oversized
rootfs becomes a build failure instead of a failed install. IMAGE_ROOTFS_SIZE
is the slot size minus whatever your image recipe adds to
IMAGE_ROOTFS_EXTRA_SPACE, and IMAGE_OVERHEAD_FACTOR is 1 so nothing inflates
it.
Pin the filesystem UUID and hash seed
One way to optimize your transferred data is to pin your filesystem UUID (provided no code or boot system relies on it). Depending on your tooling, this may look like:
EXTRA_IMAGECMD:ext4 = "-i 4096 -O metadata_csum_seed -U $UUID -E hash_seed=$HASH_SEED"
Details
Depending on your setup, your build may contain a randomly generated filesystem
UUID and hash seed for each run by default. This metadata ends up distributed
across many of the filesystem image's blocks, leading to a poor chunk reuse
ratio and increased update size overhead. You can test across two identical
builds' zck files with the zck_delta_size tool for their
size difference - any chunks that differ in two identical builds are build
noise, and will likely add overhead on each update.
Pin both values, so that unchanged metadata is byte-identical between builds,
and enable metadata_csum_seed. With Yocto, extend the default
EXTRA_IMAGECMD:
EXTRA_IMAGECMD:ext4 = "-i 4096 -O metadata_csum_seed -U 26f9859c-c6a3-41ce-828e-4fae87efd899 -E hash_seed=4253830c-df43-4193-85a8-0bc63d74e8d4"
Use any fixed pair of UUIDs, and keep them the same across releases. With
another build system, pass the same options to mkfs.ext4, and also set
E2FSPROGS_FAKE_TIME to a fixed epoch, because mkfs.ext4 otherwise writes the
current time into the superblock and into the inodes it creates itself. Yocto
builds already pin that time.
- Give the installed slot its own UUID and label. A block-level install
copies the image's UUID and label into the inactive slot, so after the first
delta update both slots carry the same values. Anything that finds the root
filesystem by UUID or label, such as
root=UUID=on the kernel command line,/dev/disk/by-uuidor/dev/disk/by-label, can then pick the wrong slot. Run a post-install script that sets them on the written partition, for exampletune2fs -U random /dev/mmcblk0p2ande2label /dev/mmcblk0p2 rootfs-b. Withmetadata_csum_seedthis costs one chunk on the next update. - Regenerated UUIDs need
metadata_csum_seedtoo. Some distributions give each filesystem a new UUID on first boot. balenaOS does this in its initramfs (fsuuidsinit). Without the feature, that onetune2fs -Uchanges every metadata checksum on the device, and no later build matches its metadata blocks. - Flash the factory slot from the same
.ext4. If your factory image is assembled withwic, apartwith--source rootfsrunsmkfsagain with its own options, so the first slot starts with a different UUID and layout from the.ext4you ship updates from. Use--source rawcopywith the built.ext4, so the flashed slot is the same filesystem as the update images.
The sw-description entry
sw-description is baked into the .swu at build time, so it cannot contain a
download URL: Memfault generates signed URLs per request. url = "dynamic" is
what resolves this. The full entry, for the A slot of an A/B pair:
copy1: {
images: (
{
filename = "base-image-qemuarm64.rootfs.ext4.zck.zckheader";
type = "delta";
device = "/dev/mmcblk0p2";
properties: {
url = "dynamic";
zckfile = "base-image-qemuarm64.rootfs.ext4.zck";
chain = "raw";
source = "/dev/mmcblk0p3";
source-size = "detect";
max-ranges = "1";
zckloglevel = "warn";
};
}
);
uboot: (
{
name = "rootpart";
value = "2";
}
);
}
| Field | Meaning |
|---|---|
filename | The detached header inside the .swu. Required |
type | delta. Required |
device | Where the reassembled image is written. The inactive partition |
url | dynamic, so the handler resolves the URL from the artifact list. Required |
zckfile | The uploaded .zck filename. The join key. Required with dynamic |
chain | The handler that performs the write - raw. Required, and it cannot be delta |
source | The block device to read chunks from. Required |
source-size | detect to stop at the end of the filesystem, a byte count, or omitted to read the whole device |
max-ranges | Ranges per HTTP request. Set it to 1. See below |
zckloglevel | zchunk's own log level: none, error, warn, info or debug. Omitted, it follows SWUpdate's -l |
- Do not set
compressed. The header member inside the.swuis stored uncompressed, and the chunk data carries its own zstd. The handler builds the chained image with compression off and a zeroedsha256. Acompressed = "zlib"copied over from a non-delta entry makes the header unreadable. - Do not set
installed-directly. The handler rejects it withDo not set installed-directly with delta, the header cannot be streamed. max-rangesmust parse as a non-zero number."0", an empty string and anything non-numeric all fall back to SWUpdate's default of 10.sourcetrades reuse against stability. The running partition gives the best reuse and is being written to while it is indexed: logs, state and/varall change between the index pass and the copy pass, and any mismatch aborts the install when the copy re-checks the digest. The inactive partition is stable but holds an older image, so reuse is lower. If you pointsourceat the running rootfs, mount it read-only.
Why max-ranges must be 1
The delta handler coalesces up to max-ranges missing-chunk ranges into one
HTTP Range header. The delta downloader then requires a 206 Partial Content
response:
ERROR : Bytes request not supported by server, returning 200
For this reason, any storage CDNs that do not support multi-part range requests
would cause a 200 response, breaking the delta update. Set max-ranges to 1.
With max-ranges = 1 and the coalescing patch, the number of HTTP requests
equals the number of contiguous runs of missing chunks in the target .zck,
not the number of missing chunks. All of them share one TCP connection and one
TLS handshake. This greatly improves the efficiency of the delta update.
Two SWUpdate patches we ship
The Memfault Linux SDK example layer carries two patches against SWUpdate to fix
two bugs in the delta handler that greatly reduce efficiency of an update. These
patches target 2026.05.1 and live in
recipes-support/swupdate/files.
Patch 1: Coalesce adjacent chunks up to max-ranges. In
handlers/zchunk_range.c, upstream checks the range counter immediately after
adding a chunk, which is one chunk too early for the merge of adjacent ranges to
have happened. With max-ranges = 1 the merge can therefore never happen at
all: every missing chunk gets its own HTTP request, even when hundreds of them
are consecutive in the file.
Patch 2: Open the download channel once for all requests. In
handlers/delta_downloader.c, upstream opens and closes the HTTP channel inside
the request loop. The libcurl easy handle owns the connection cache, the DNS
cache and the TLS session cache, so destroying it after every answer means every
range request pays a new TCP connection and a full TLS handshake against the
same URL. The patch opens the channel once before the loop.
Uploading the two payloads
Both files go up in one upload-zchunk-ota-payload command, which uploads them
as two payloads of one Full Release:
$ memfault upload-zchunk-ota-payload \
--hardware-version ${YOUR_HARDWARE_VERSION} \
--software-type ${YOUR_SOFTWARE_TYPE} \
--software-version ${YOUR_SOFTWARE_VERSION} \
--zchunk-header tmp/deploy/images/qemuarm64/swupdate-delta-image-qemuarm64.rootfs.swu \
--zchunk-image tmp/deploy/images/qemuarm64/base-image-qemuarm64.rootfs.ext4.zck
| Option | File | Role |
|---|---|---|
--zchunk-header | .swu | sw-description plus the detached header. The installable payload |
--zchunk-image | .zck | The chunk data the device fetches ranges from |
A zchunk delta Release is always a Full Release, so --software-version is
required and --delta-from/--delta-to do not apply. upload-ota-payload is
unchanged and stays the command for every other payload.
Before it uploads anything, the command reads the detached header out of the
.swu and compares it with the header of the .zck. A pair from two different
builds is rejected and neither file is uploaded, so a mismatched pair cannot
reach a device. On success it prints the header member name and its checksum.
Replacing one half of a pair is not a supported operation. The header inside the
.swu describes one specific .zck, and a mismatched pair fails on chunk
digests at install time. To change either file, build and upload both again. The
.zck cannot be deleted on its own either: deleting the OTA payload deletes
both.
Memfault does not offer a Release to a device until every artifact it has to deliver has finished processing.
The artifact type shows up as firmware_type in the artifacts API and in the
Payloads table on the Release page: zchunk_header_swu for the .swu and
zchunk_image for the .zck. On the
/latest endpoint the .swu
appears in artifacts and the .zck in companion_artifacts. The hawkBit DDI
deployment lists both, which is what the delta handler needs.
Uploading with the REST API
Without the CLI, upload each payload with the two-step prepared upload flow and
specify its role with firmware_type on the commit request. Run the flow twice,
once per file. See Upload Release Artifact for
the full request body.
-
Prepare the upload. The response carries
data.upload_urlanddata.token:$ curl -X POST \--header "Authorization: Bearer ${ORGANIZATION_AUTH_TOKEN}" \--header "Content-Type: application/json" \--data '{"kind": "OTA_PAYLOAD", "size": 182863255}' \"https://files.memfault.com/api/v0/organizations/${YOUR_ORG_SLUG}/projects/${YOUR_PROJECT_SLUG}/upload" -
PUTthe file todata.upload_url. -
Commit the payload with the token from step 1 and
firmware_typeset:$ curl -X POST \--header "Authorization: Bearer ${ORGANIZATION_AUTH_TOKEN}" \--header "Content-Type: application/json" \--data @- \"https://files.memfault.com/api/v0/organizations/${YOUR_ORG_SLUG}/projects/${YOUR_PROJECT_SLUG}/releases/ota_payload" <<EOF{"file": { "token": "${UPLOAD_TOKEN}" },"software_version": {"version": "${YOUR_SOFTWARE_VERSION}","software_type": "${YOUR_SOFTWARE_TYPE}"},"hardware_version": "${YOUR_HARDWARE_VERSION}","firmware_type": "zchunk_image"}EOF
firmware_type | File | Role |
|---|---|---|
zchunk_header_swu | .swu | sw-description plus the detached header. The installable payload |
zchunk_image | .zck | The chunk data the device fetches ranges from |
standalone | any | The default. Every payload that is installed on its own |
The commit returns 202 and processing continues asynchronously. An unknown
firmware_type is rejected with 400 and the error keyed on firmware_type,
and one artifact carries exactly one firmware_type, so the two files are
always two uploads. A file larger than the project's OTA payload size limit, 5
GB by default, is rejected with 400.
This path does not check the pair. The header comparison described above runs in
the CLI, not in the service, so a .swu and a .zck from two different builds
are both accepted here and fail on chunk digests on the device. Compare the two
headers in your own tooling, or use upload-zchunk-ota-payload.
The filename contract
The .zck filename must equal properties.zckfile. url = "dynamic" makes
suricatta register the .zck URL in a dictionary keyed by the artifact filename
Memfault sent, and the delta handler looks that key up using
properties.zckfile from your sw-description. The key Memfault sends is
Artifact.filename, which is the basename of the file you passed to the CLI.
Only the last path component matters.
A mismatch fails the install with a null URL:
ERROR : Wrong Attributes in sw-description: url=(null) source=/dev/mmcblk0p3, handler=raw
If you generate sw-description from your build, substitute the deployed
basename into the template so the two match by construction, as the SDK example
recipe does. Do not rename either artifact after the build.
The .zck filename must end in .zck. suricatta dispatches on the last
dot-separated component of the filename and has exactly two rules, swu and
zck. An artifact with any other extension hits a reject rule whose return
value upstream discards, so the artifact is neither skipped nor registered: it
is handed to the installer as though it were an SWU, and fails mid-install with
a confusing error. A file with no extension at all is skipped.
Filenames are also truncated silently at 255 bytes and URLs at 1023 bytes when they cross SWUpdate's IPC boundary. Keep filenames short.
Verifying the delta transfer
Check bytes transferred, not just a successful install. The delta handler logs
the split at INFO before the transfer starts, and the actual total when it
finishes:
INFO : Total bytes to be reused : 178481280
INFO : Total bytes to be downloaded : 4381975
INFO : Size of artifact to be installed : 182863255
...
INFO : Total downloaded data : 4385792 bytes
Total bytes to be reused is the uncompressed size of the chunks found on the
source partition. Total bytes to be downloaded is the compressed size of the
chunks that are missing, so it is comparable to the size of the .zck.
Size of artifact to be installed is the uncompressed size of the whole image.
Two things to look for:
Total bytes to be reusedis 0. The target.zckwas built without-u. Seezck -uis mandatory. There is no log line for this.fallback to full downloadin the log. The exact line isWARN : [install_delta] : ZCK Header form /dev/mmcblk0p3 cannot be created, fallback to full download. The device could not index its source partition, so nothing is reused and the whole image is fetched. Note the severity: this is aWARN, and the update still succeeds.
Device resource requirements
- The write target needs no scratch space. The reassembled image is piped to
the
rawhandler and written straight to the partition. - The source index is not written to disk. SWUpdate sets zchunk's
ZCK_NO_WRITEand logsZCK does not support NO Write, use huge amount of RAMif the linked zchunk does not support it. On a build where that warning appears, indexing the source costs memory proportional to its size. - The header is spooled in RAM. The detached header is copied into a
memfdbefore it is parsed. Header size scales with chunk count: about 250 KB for a 195 MB image at default chunk sizes, and several MB for a multi-GB image. - Give the device headroom. You may hit
Error writing data: No space left on deviceif the device does not have sufficient RAM. Size RAM and/tmpagainst your image, and run an install on the real hardware before shipping.
Limits
| Limit | Value | Failure mode |
|---|---|---|
URL and Range header combined | 32766 bytes | Range exceeds maximum 32767 bytes !, abort |
| Registered URL length | 1023 bytes | Silent truncation, then 403 or 404 on every range |
| Registered artifact filename | 255 bytes | Silent truncation, then a filename lookup miss |
.zck payloads | One per Release and Hardware Version | A second delta image has no .zck to resolve |
| Signed CDN URL lifetime | About 6 hours | An update still running past expiry has to restart |
| hawkBit pending deployment lifetime | About 6 hours | The device is moved to an error state and retries |
An update that cannot finish inside the URL lifetime has to restart. On a low-bandwidth link, measure a full install before committing to a rollout, and see Low bandwidth devices.
Troubleshooting
| Symptom | Cause |
|---|---|
Total bytes to be reused is 0, update succeeds | The .zck was built without -u, or over a compressed image |
| Megabytes downloaded for a change of a few KB | mkfs.ext4 drew a new UUID and hash seed. See Pin the UUID |
ZCK Header form ... cannot be created, fallback to full download (WARN) | The source partition could not be indexed. Check source, and RAM |
Wrong Attributes in sw-description: url=(null) ... | properties.zckfile does not match the uploaded .zck filename |
Bytes request not supported by server, returning 200 | A request carried more than one range. Set max-ranges = "1" and apply the coalescing patch |
| One HTTP request per missing chunk | The coalescing patch is not applied |
Chunks dowbnloader is not running, delta update not available ! | The downloader could not open its HTTP channel at startup |
cannot write N bytes: No space left on device at the end of the transfer | The image is larger than the target partition. See Image size |
ZCK does not support NO Write, use huge amount of RAM | The linked zchunk ignores ZCK_NO_WRITE; indexing then costs memory proportional to the source |
The device is offered no update after uploading the .swu | The .zck has not finished processing. Both payloads must be ready before the Release is offered |
| Install fails on chunk digests | The .swu header and the .zck are from different builds. Rebuild and upload both |
The .zck is downloaded and handed to the installer as an update | Its filename does not end in .zck, so suricatta neither skipped it nor registered its URL |