Engineering Article

Isolate Cloud Mac CI Workspaces with Encrypted Sparse Images

Isolate Cloud Mac CI Workspaces with Encrypted Sparse Images

When the same cloud Mac runs multiple build jobs in succession, the hardest problems to detect are often not compilation failures, but source code, temporary certificates, test data, or caches left behind by one job and accessed by the next. Running rm -rf after every job is not reliable enough: processes may still have files open, hidden directories can easily be missed, and abnormal termination may skip cleanup altogether. A clearer boundary is to mount a separate encrypted sparse image for each job and keep checkouts, builds, and temporary artifacts within that file system.

Why use sparse images for isolation?

A sparse image can have a large logical capacity while consuming host disk space only as data is written. It remains a regular file, making it easy to locate, measure, and delete by job ID. Once mounted, it behaves as a separate APFS volume, so existing build scripts usually need only a change to their working directory.

Encryption protects data while the image is not mounted, while a dedicated mount point establishes a path boundary between jobs. Neither replaces per-user isolation or least-privilege access, but together they are easier to audit than a shared, long-lived working directory.

Do not assume that an encrypted image is inaccessible while a job is running. Once mounted, its contents remain readable by processes with the appropriate file permissions. The runner account, logging, and secret-injection method must therefore be secured as part of the same design.

ApproachResidue after abnormal terminationJob boundarySuitable use
Delete a shared directory afterwardOpen files and hidden directories are easily missedDepends on script correctnessShort jobs without sensitive data
One regular directory per jobThe directory remains on the host volumeSeparate paths, but not separate file systemsLow-risk concurrent builds
Encrypted sparse imageLeftover mounts and image files can be identifiedIndependent file systemCI requiring a clear cleanup boundary

Create an APFS workspace named after each job

First restrict the job ID to an approved character set so that slashes, spaces, or command substitutions cannot enter the path. Store images in a location readable and writable only by the runner account, and create a separate mount point for each job.

set -euo pipefail
set +x
umask 077

JOB_KEY="$(printf '%s' "${CI_JOB_ID:?}" | tr -cd 'A-Za-z0-9._-')"
IMAGE_ROOT="$HOME/ci-images"
IMAGE_PATH="$IMAGE_ROOT/$JOB_KEY.sparsebundle"
MOUNT_PATH="/Volumes/ci-$JOB_KEY"

mkdir -p "$IMAGE_ROOT" "$MOUNT_PATH"
chmod 700 "$IMAGE_ROOT"

printf '%s' "${CI_VOLUME_PASSWORD:?}" |
  hdiutil create \
    -size 80g \
    -type SPARSEBUNDLE \
    -fs APFS \
    -volname "ci-$JOB_KEY" \
    -encryption AES-256 \
    -stdinpass \
    "$IMAGE_PATH"

80g is a logical limit; it does not immediately consume 80GB. Set the limit high enough for source code, dependencies, derived data, and peak archive size, with additional headroom for failure logs. Never put the password in command arguments, file names, or build logs. Disable command tracing before passing it through standard input from a protected CI variable.

Verify the mount immediately

Successful creation does not guarantee that the image is mounted at the intended location. The script should verify that the target path is a mounted volume and confirm its file-system type.

printf '%s' "$CI_VOLUME_PASSWORD" |
  hdiutil attach \
    -stdinpass \
    -nobrowse \
    -mountpoint "$MOUNT_PATH" \
    "$IMAGE_PATH"

mount | grep -F "on $MOUNT_PATH "
diskutil info "$MOUNT_PATH" | grep -E 'File System Personality|Volume Name'
mkdir -p "$MOUNT_PATH/src" "$MOUNT_PATH/output" "$MOUNT_PATH/tmp"

Then point the source checkout, build output, and job-specific temporary directory to this volume. Shared read-only package-manager caches can remain outside the volume, but any cache that a job may modify should be copied into it to prevent contamination from concurrent writes.

Make detachment part of the lifecycle, not a final command

Cleanup cannot exist only on the last line of a script, because compilation errors, timeouts, and termination signals can end a job early. Use an exit trap to handle synchronization, open-file checks, and detachment consistently, while preserving enough diagnostic information when cleanup fails.

cleanup_workspace() {
  set +e
  sync
  if mount | grep -Fq "on $MOUNT_PATH "; then
    lsof +D "$MOUNT_PATH" > "$IMAGE_ROOT/$JOB_KEY.lsof.txt" 2>/dev/null
    hdiutil detach "$MOUNT_PATH"
  fi
  rmdir "$MOUNT_PATH" 2>/dev/null
}

trap cleanup_workspace EXIT INT TERM
export TMPDIR="$MOUNT_PATH/tmp"
cd "$MOUNT_PATH/src"

lsof +D can be slow on a large directory. You can first check known build, test, and packaging processes, then run a full scan only if detachment fails. Do not use forced detachment as the first response: it can hide processes that are still writing and leave incomplete artifacts behind.

Handle concurrency, capacity, and failed-job residue

When the same job is retried, its previous image may still exist. Safely handling this does not mean overwriting it immediately. First check whether it is mounted. If it is, block the new job and record the conflict. If it is not, archive or delete it according to the retention policy. The job ID should also include the current run number so that two runs never point to the same image.

Establish three checks

First, inspect hdiutil info before the job starts to confirm that no volume with the same name exists. Second, monitor free space on both the host and mounted volumes during the build. Sparse images grow over time, so free space inside the logical volume does not imply available space on the host. Third, scan the image directory after the job finishes and retain only failed-job samples explicitly marked for diagnostics.

Use the following commands to distinguish the two capacity layers:

df -h "$MOUNT_PATH"
df -h "$IMAGE_ROOT"
du -sh "$IMAGE_PATH"
hdiutil info

If a class of jobs frequently reaches the limit, first separate intermediate artifacts that do not require long-term retention, then adjust the logical capacity. Blindly increasing the image size only postpones the point at which the host disk is exhausted.

Security boundaries and deployment checklist

The secret should enter the process only while the image is being created and mounted, and should be unset from the current shell immediately afterward. Build scripts must not print environment variables or copy the password into the volume. Keep image file permissions at 600 or stricter, and the image root directory at 700.

Before deployment, verify each of the following:

  • The runner uses a dedicated non-administrator account;
  • The job ID is filtered through an allowlist, preventing path traversal;
  • Creation, mounting, building, and detachment can each fail independently and return a clear status;
  • EXIT, INT, and TERM all invoke the same cleanup function;
  • If detachment fails, open processes are recorded before any forced action is taken;
  • Capacity is monitored for the APFS volume, sparse image file, and host volume;
  • Failed-job samples have a defined retention period, after which the entire image is deleted;
  • Logs contain no passwords, private keys, complete credentials, or sensitive source-code excerpts.

Start with a small project containing no sensitive data, and test four paths: successful completion, compilation failure, manual termination, and a disk approaching its capacity limit. This isolation model is operationally ready only when the image can be located, its state explained, and its resources reclaimed in all four cases.

Frequently asked questions

Can an encrypted sparse image replace operating-system permission isolation?

No. It protects workspace data and reduces exposure from files left at rest, but it should be combined with a dedicated runner user, minimal permissions, controlled secret injection, and reliable unmounting.

What should I do when a failed job leaves the image mounted?

Use lsof to identify processes holding the mount point, stop only the related job processes, run sync, and attempt a normal detach. Force detachment should be the last step after writes have stopped.

Dedicated Cloud Mac

Choose a dedicated physical machine for development, builds, and remote desktop access

Compare three Apple Silicon configurations and choose the node, rental term, and storage add-ons at checkout.

Choose a rental plan