Large Artifact Uploads on GitHub Actions

Artifact uploads run slow because upload-artifact zips the whole path at compression level 6 before any byte moves. Cut the file count, bytes, and minutes.

GitHub Actions artifact uploads run slow because actions/upload-artifact assembles everything under the path into one Zip archive at compression level 6 before a single byte leaves the runner, and that pass pays a fixed cost for every file in the tree. Three levers fix it: shrink the file count the archive step sees by tarring first, drop the compression level when the payload is already compressed, and split one monolithic artifact into artifacts scoped to their consumers.

This page covers where the minutes actually go inside an upload step, the workflow change that removes them, the configuration that keeps a matrix from colliding on artifact names, and a transfer model that prices artifact size against upload minutes with the registry alternative in the same table. It sits under the guide to speeding up GitHub Actions.

Diagnosis

An upload step charges for three things, and they scale on different axes.

Archive construction. All files under the path are assembled into a single immutable Zip archive, and the upload-artifact documentation states there is currently no way to download an artifact in another format or to download individual contents from it. The archive is built with Zlib at a compression-level that defaults to 6, the same as GNU Gzip, on a scale of 0 to 9. Higher levels compress better and take longer. The documentation recommends 0 for large files that do not compress well, because the deflate pass costs CPU and returns almost nothing.

Per-file overhead. The archive pass opens, stats, deflates, and writes a central directory entry for every file it finds. That cost is fixed per file and independent of file size. A dist tree with 62,000 small files and source maps pays 62,000 of them; one 1.8 GB tarball pays one. This is why a 300 MB JavaScript build directory can take longer to upload than a 2 GB single-file installer.

Transfer. Only the third cost moves bytes, and it scales with post-compression size and with runner throughput. On most upload steps that feel slow, this is the smallest of the three.

The consumer side doubles the bill. Because there is no partial download, a job that needs a 4 MB JUnit report out of a 620 MB monolithic artifact downloads and unzips all 620 MB. Fan-out multiplies that: one producer and five consumers means six passes over the same archive for one commit.

Four documented constraints shape what you can do about it, all checked on 2026-08-13 against the upload-artifact README:

  • Each job has a limit of 500 artifacts.
  • Uploading to the same artifact name more than once fails, because artifacts created by v4 are immutable. Matrix legs need distinct names.
  • File permissions are dropped during a zipped upload. All directories land at 755 and all files at 644, so an executable stops being executable after download.
  • From v4.4 onward, hidden files are excluded by default and need include-hidden-files: true to travel.

Measure four numbers before changing a line.

QuestionHow to get itWhat it decides
Bytes under the upload pathdu -sb dist in the step before the uploadThe transfer share
File count under the pathfind dist -type f | wc -lThe per-file overhead share
Time inside the upload stepStep duration in the job log, or the Duration P75 and P90 columns in ReportsThe baseline the model gets compared to
Consumers per artifactCount the download-artifact steps naming itThe fan-out multiplier

The Jobs section of Reports aggregates duration and queue time per repository, workflow, and job name, with CSV export, which is the fastest way to see whether the upload job or the download jobs carry the regression.

Fix

1. Tar the tree before the upload step. One stream replaces one Zip entry per file, so the archive cost moves from file count to bytes. Tar also keeps permissions and case sensitivity that the Zip path drops.

2. Compress once, with a codec you choose. tar -I 'zstd -3' produces a compact bundle quickly. Then set compression-level: 0 on the upload so the action stores the bundle instead of deflating already compressed data a second time.

3. Split the monolith into artifacts scoped to consumers. A deploy job wants the app bundle. A reviewer wants the JUnit XML. A coverage gate wants one LCOV file. Three artifacts let each consumer download only its own payload, which the no-partial-download rule makes impossible inside one artifact.

4. Narrow the path. Exclude source maps, .git, intermediate object directories, and test fixtures the consumers never open. Every excluded file removes both an archive entry and a download byte.

5. Set retention-days to match the purpose. A within-run handoff needs a day or two. Ninety days is the default, and the retention window is covered in how long GitHub Actions artifacts are kept.

6. Skip artifact storage entirely for container payloads. When the build output is going to become an image anyway, build and push it to your registry from a remote Docker builder and have downstream jobs pull it. Layers that did not change are not re-pushed or re-pulled, which is a different scaling curve from re-zipping the whole tree every run. The promotion pattern around that is in build once, deploy many in GitHub Actions.

Configuration

The workflow below archives once, uploads two targeted artifacts, and hands the bundle to a deploy job.

name: build

on:
  pull_request:
  push:
    branches: [main]

jobs:
  build:
    runs-on: warp-ubuntu-latest-x64-8x
    steps:
      - uses: actions/checkout@v4

      - run: ./build.sh

      - name: Archive the app bundle
        run: tar -I 'zstd -3 -T0' -cf app-bundle.tar.zst -C dist .

      - name: Upload the app bundle
        uses: actions/upload-artifact@v4
        with:
          name: app-bundle
          path: app-bundle.tar.zst
          compression-level: 0
          retention-days: 7

      - name: Upload the test report
        uses: actions/upload-artifact@v4
        with:
          name: test-report
          path: reports/junit/*.xml
          if-no-files-found: error
          retention-days: 30

  deploy:
    needs: build
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: app-bundle

      - run: |
          mkdir -p dist
          tar -I zstd -xf app-bundle.tar.zst -C dist

Three details carry the design. compression-level: 0 matters because the tarball is already zstd compressed, and deflating it again spends CPU for a rounding error of size. -T0 lets zstd use every core on the label, which is where a larger runner buys back archive time. And if-no-files-found: error on the report upload turns a silently empty artifact into a failed job, since the default is warn.

Matrix builds need one more rule. Artifact names must be unique across the run, so every leg carries the matrix values in its name:

    strategy:
      matrix:
        runner: [warp-ubuntu-latest-x64-8x, warp-macos-latest-arm64-6x]
        target: [debug, release]

    steps:
      - uses: actions/upload-artifact@v4
        with:
          name: bundle-${{ matrix.runner }}-${{ matrix.target }}
          path: app-bundle.tar.zst
          compression-level: 0

A later job pulls the whole set with actions/download-artifact@v4 and a pattern: bundle-* plus merge-multiple: true, so fan-in costs one step instead of one step per leg.

Platform coverage decides how much of this you need. The permission-loss rule bites hardest on the macOS legs: an .app bundle uploaded as a plain Zip artifact comes back with every file at 644, so a signed binary stops being executable and codesign verification fails downstream. Tarring first is what keeps that leg working, and the packaging side of it is in publishing build artifacts from GitHub Actions.

Cost or Time Model

Model inputs, all replaceable with numbers from your own run history:

  • A dist tree of 1,800 MB across 62,000 files.
  • Archive at level 6: 2.5 ms of fixed cost per file plus 45 MB per second of deflate throughput. Output 620 MB.
  • Archive with tar -I 'zstd -3 -T0': 0.4 ms per file plus 380 MB per second. Output 640 MB.
  • The store-only upload pass at compression-level: 0 moves 350 MB per second.
  • 120 MB per second of sustained transfer to and from artifact storage.
  • Unzip writes 62,000 files at 1.2 ms each; tar extraction writes them at 0.5 ms each.
  • One producer, five consumers, 700 runs per month.
  • warp-ubuntu-latest-x64-8x at $0.016 per minute, from the pricing page, checked on 2026-08-13.
LineOne artifact at level 6Split artifacts, tar and zstd
Files entering the archive step62,0001
Archive time3.25 min0.49 min
Store-only upload passnot applicable0.03 min
Bytes transferred up620 MB640 MB
Upload transfer time0.09 min0.09 min
Producer step total3.34 min0.61 min
Consumer work5 downloads and unzips at 1.33 min1 bundle extract at 0.61 min, 4 report downloads at 0.01 min
Consumer total6.65 min0.65 min
Runner minutes per run9.991.26
Cost per run$0.16$0.02
700 runs per month$111.89$14.11

The split column removes 8.73 minutes of runner time per run and $97.78 per month at this shape. Most of that comes from the consumer row rather than the producer row, because the no-partial-download rule made four jobs pay for a payload they never opened.

Now the registry alternative in the same units. A remote Docker builder at 16 vCPU and 32 GB lists at $0.06 per minute on the pricing page, checked on 2026-08-13, and it is billed for the session rather than for the runner it serves.

Handoff pathProducer minutesConsumer minutesCost per run700 runs per month
One monolithic artifact at level 63.34 runner6.65 runner$0.16$111.89
Split artifacts, tar and zstd0.61 runner0.65 runner$0.02$14.11
Registry push from a remote Docker builder0.40 runner plus 0.40 builder0.75 runner$0.04$29.68

The registry row costs more per run than split artifacts for a single in-run handoff, and it wins on a different axis. Layers that did not change are not pushed and not pulled, so the marginal run moves a delta rather than a fresh 640 MB bundle, and the same digest is addressable from a deploy workflow days later instead of expiring with the artifact retention window. Pick the registry when the payload is going to become an image anyway or when consumers live outside the run; pick split artifacts when the payload is reports, binaries, and bundles that only the run itself reads.

Label choice is the last variable, and it only helps the archive row. Rates below come from the pricing page, checked on 2026-08-13:

Runner labelvCPURAMPrice per minute
warp-ubuntu-latest-x64-4x416 GB$0.008
warp-ubuntu-latest-x64-8x832 GB$0.016
warp-ubuntu-latest-x64-16x1664 GB$0.032
warp-ubuntu-latest-arm64-8x832 GB$0.012
warp-macos-latest-arm64-6x622 GB$0.08

The Zip pass inside upload-artifact is single-stream, so moving up a size does nothing for the level 6 column. A tar -I 'zstd -3 -T0' pass is parallel, so it does scale with vCPU, which is the specific reason the fix above is worth more on a 16x label than the original step was.

One egress note for teams whose artifacts live in object storage rather than in GitHub. On the enterprise tier, runners pull large artifacts from ECR, S3, and similar stores with zero egress cost to you, whether the runners sit in your cloud or ours; the scope is on zero egress for the enterprise tier.

FAQ

Why is artifact upload slow in GitHub Actions?

Because the upload step does two jobs. It assembles every file under the path into one Zip archive at Zlib compression level 6 by default, then transfers the result. The archive pass pays a fixed cost per file, so a tree of tens of thousands of small files spends most of the step walking and deflating rather than moving bytes across the network.

Should I set compression-level to 0 for artifact uploads?

Set it to 0 when the payload is already compressed, such as a tar.zst bundle, a video, or a signed installer. The upload-artifact documentation recommends 0 for large files that do not compress well, because the deflate pass costs CPU time and returns almost no size reduction. Keep the default of 6 for plaintext, and reserve 9 for cases where storage matters more than minutes.

Is it faster to tar a directory before uploading it as an artifact?

For trees with many files, yes. A tar pass writes one stream instead of one Zip entry per file, so the archive step scales with bytes rather than with file count. Tar also preserves file permissions and case sensitivity, which the Zip path drops by setting all directories to 755 and all files to 644.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.