Skip to main content

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.

tip

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.

FileContainsSizeHow the device gets it
<image>.swusw-description plus the detached zchunk headerA few hundred KBDownloaded in full
<image>.ext4.zckThe chunk index and the chunk data for the whole imageA full firmware imageFetched by HTTP Range

An update then runs like this:

  1. The device polls Memfault over the hawkBit DDI API. The deployment lists both artifacts, each with its own signed CDN URL.
  2. SWUpdate's suricatta daemon classifies each artifact by file extension. The .swu is installed. The .zck is marked as skipped and its URL is handed to the installer instead of being downloaded.
  3. The installer unpacks the .swu, reads sw-description, and gives the detached header to the delta handler.
  4. The delta handler reads the source partition end to end and builds an index of its chunks, hashing the uncompressed data.
  5. The handler compares the source index against the target header and marks each target chunk as present or missing.
  6. Present chunks are copied from the source partition. Missing chunks are fetched from the .zck with HTTP range requests, and each one is verified against its digest in the header.
  7. The reassembled image is piped to a chained handler, raw, which writes it to the inactive partition.
  8. suricatta reports the result back to Memfault.

Requirements​

  • SWUpdate 2026.05 or later. The dynamic URL mechanism that lets the delta handler resolve the .zck URL at install time landed upstream in January 2026 and was first tagged in 2026.05. Earlier versions cannot be used with Memfault-signed URLs, because the URL is not known at build time.
  • CONFIG_DELTA=y and CONFIG_ZSTD=y in your SWUpdate configuration. CONFIG_DELTA pulls in the delta handler and the delta downloader.
  • CONFIG_DISKFORMAT=y, if you use source-size = "detect". Without it, SWUpdate logs SWUPdate not compiled with DISKFORMAT, skipping size detection and 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 = 1 issues 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 zck and unzck.
  • 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 .zck payload. The delta handler forces is_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}:

FileWhat to do with it
swupdate-delta-image-${MACHINE}.rootfs.swuPass as --zchunk-header
base-image-${MACHINE}.rootfs.ext4.zckPass as --zchunk-image
base-image-${MACHINE}.rootfs.ext4.zck.zckheaderNothing. It is already inside the .swu
caution

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.

caution
  • 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-uuid or /dev/disk/by-label, can then pick the wrong slot. Run a post-install script that sets them on the written partition, for example tune2fs -U random /dev/mmcblk0p2 and e2label /dev/mmcblk0p2 rootfs-b. With metadata_csum_seed this costs one chunk on the next update.
  • Regenerated UUIDs need metadata_csum_seed too. Some distributions give each filesystem a new UUID on first boot. balenaOS does this in its initramfs (fsuuidsinit). Without the feature, that one tune2fs -U changes 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 with wic, a part with --source rootfs runs mkfs again with its own options, so the first slot starts with a different UUID and layout from the .ext4 you ship updates from. Use --source rawcopy with 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";
}
);
}
FieldMeaning
filenameThe detached header inside the .swu. Required
typedelta. Required
deviceWhere the reassembled image is written. The inactive partition
urldynamic, so the handler resolves the URL from the artifact list. Required
zckfileThe uploaded .zck filename. The join key. Required with dynamic
chainThe handler that performs the write - raw. Required, and it cannot be delta
sourceThe block device to read chunks from. Required
source-sizedetect to stop at the end of the filesystem, a byte count, or omitted to read the whole device
max-rangesRanges per HTTP request. Set it to 1. See below
zckloglevelzchunk's own log level: none, error, warn, info or debug. Omitted, it follows SWUpdate's -l
caution
  • Do not set compressed. The header member inside the .swu is stored uncompressed, and the chunk data carries its own zstd. The handler builds the chained image with compression off and a zeroed sha256. A compressed = "zlib" copied over from a non-delta entry makes the header unreadable.
  • Do not set installed-directly. The handler rejects it with Do not set installed-directly with delta, the header cannot be streamed.
  • max-ranges must parse as a non-zero number. "0", an empty string and anything non-numeric all fall back to SWUpdate's default of 10.
  • source trades reuse against stability. The running partition gives the best reuse and is being written to while it is indexed: logs, state and /var all 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 point source at 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
OptionFileRole
--zchunk-header.swusw-description plus the detached header. The installable payload
--zchunk-image.zckThe 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.

  1. Prepare the upload. The response carries data.upload_url and data.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"
  2. PUT the file to data.upload_url.

  3. Commit the payload with the token from step 1 and firmware_type set:

    $ 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_typeFileRole
zchunk_header_swu.swusw-description plus the detached header. The installable payload
zchunk_image.zckThe chunk data the device fetches ranges from
standaloneanyThe 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 reused is 0. The target .zck was built without -u. See zck -u is mandatory. There is no log line for this.
  • fallback to full download in the log. The exact line is WARN : [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 a WARN, and the update still succeeds.

Device resource requirements​

  • The write target needs no scratch space. The reassembled image is piped to the raw handler and written straight to the partition.
  • The source index is not written to disk. SWUpdate sets zchunk's ZCK_NO_WRITE and logs ZCK does not support NO Write, use huge amount of RAM if 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 memfd before 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 device if the device does not have sufficient RAM. Size RAM and /tmp against your image, and run an install on the real hardware before shipping.

Limits​

LimitValueFailure mode
URL and Range header combined32766 bytesRange exceeds maximum 32767 bytes !, abort
Registered URL length1023 bytesSilent truncation, then 403 or 404 on every range
Registered artifact filename255 bytesSilent truncation, then a filename lookup miss
.zck payloadsOne per Release and Hardware VersionA second delta image has no .zck to resolve
Signed CDN URL lifetimeAbout 6 hoursAn update still running past expiry has to restart
hawkBit pending deployment lifetimeAbout 6 hoursThe 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​

SymptomCause
Total bytes to be reused is 0, update succeedsThe .zck was built without -u, or over a compressed image
Megabytes downloaded for a change of a few KBmkfs.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 200A request carried more than one range. Set max-ranges = "1" and apply the coalescing patch
One HTTP request per missing chunkThe 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 transferThe image is larger than the target partition. See Image size
ZCK does not support NO Write, use huge amount of RAMThe linked zchunk ignores ZCK_NO_WRITE; indexing then costs memory proportional to the source
The device is offered no update after uploading the .swuThe .zck has not finished processing. Both payloads must be ready before the Release is offered
Install fails on chunk digestsThe .swu header and the .zck are from different builds. Rebuild and upload both
The .zck is downloaded and handed to the installer as an updateIts filename does not end in .zck, so suricatta neither skipped it nor registered its URL