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: trueto travel.
Measure four numbers before changing a line.
| Question | How to get it | What it decides |
|---|---|---|
| Bytes under the upload path | du -sb dist in the step before the upload | The transfer share |
| File count under the path | find dist -type f | wc -l | The per-file overhead share |
| Time inside the upload step | Step duration in the job log, or the Duration P75 and P90 columns in Reports | The baseline the model gets compared to |
| Consumers per artifact | Count the download-artifact steps naming it | The 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 distThree 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: 0A 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
disttree 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: 0moves 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-8xat $0.016 per minute, from the pricing page, checked on 2026-08-13.
| Line | One artifact at level 6 | Split artifacts, tar and zstd |
|---|---|---|
| Files entering the archive step | 62,000 | 1 |
| Archive time | 3.25 min | 0.49 min |
| Store-only upload pass | not applicable | 0.03 min |
| Bytes transferred up | 620 MB | 640 MB |
| Upload transfer time | 0.09 min | 0.09 min |
| Producer step total | 3.34 min | 0.61 min |
| Consumer work | 5 downloads and unzips at 1.33 min | 1 bundle extract at 0.61 min, 4 report downloads at 0.01 min |
| Consumer total | 6.65 min | 0.65 min |
| Runner minutes per run | 9.99 | 1.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 path | Producer minutes | Consumer minutes | Cost per run | 700 runs per month |
|---|---|---|---|---|
| One monolithic artifact at level 6 | 3.34 runner | 6.65 runner | $0.16 | $111.89 |
| Split artifacts, tar and zstd | 0.61 runner | 0.65 runner | $0.02 | $14.11 |
| Registry push from a remote Docker builder | 0.40 runner plus 0.40 builder | 0.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 label | vCPU | RAM | Price per minute |
|---|---|---|---|
warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 |
warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 |
warp-ubuntu-latest-x64-16x | 16 | 64 GB | $0.032 |
warp-ubuntu-latest-arm64-8x | 8 | 32 GB | $0.012 |
warp-macos-latest-arm64-6x | 6 | 22 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.