If you are testing a website, the DEV dependencies do not change very often. You might bump Cypress version once in a while, add or upgrade a Cypress plugin, but in general the Node dependencies are changed less frequently than the spec files. Thus running npm ci on every test job is bound to be repetitive work that slows down your testing pipelines.
Of course, each CI allows you to cache dependencies between the jobs - but why do you need to even think about it? You want to cache all dependencies the very first time npm ci runs for the given package.json or package-lock.json file, not the first time each the workflow runs! Most CIs let you control the container image used for running tests; I assume you use one of Cypress Docker images or one of Cypress Docker images with browsers installed. So what if we could create our own Docker image based on Cypress (or any appropriate Docker base image you might want) and run npm ci in that image, and then use that Docker image to simply run our current tests?
I had the idea of caching separately PROD and DEV images inside Docker containers a loooong time ago, see the bahmutov/double-docker repo. In this blog post, I will show a simple approach that works for repositories with tests only, the situation described in the blog post Separate Application And Tests Repos GitHub Actions Setup. We will use GitHub Actions and the public Docker registry.
In the nutshell:
- whenever the user changes
package.jsonorDockerfile, we build a new Docker image with thenode_modulesand Cypress binary folders (but no other source code) - we push the built Docker image to the registry
- every CI run simply pulls that Docker image and checks out the tests into the container
- the tests run immediately after the checkout step, since there is nothing to install
Let's see the code
🎁 I have the working example in the public repo bahmutov/cypress-tests-image.
Dockerfile
First, let's look a the Dockerfile
1 | # pick the image to build from |
Tip: you can use yarn.lock or any other lock file with this approach.
I am naming my image bahmutov/cy:<tag>, you can find them at the Docker hub. Note that it is important to build the image on the same OS architecture as the CI machines, in my case it will be ubuntu-latest.
Workflow
The first step in the workflow computes the combined checksum of package.json and Dockerfile files. It also checks if the Docker image tagged with this checksum exists already using action tyriis/docker-image-tag-exists.
1 | name: CI |
Build and push
Great, let's build the Docker image if one is missing. We could have a job with every internal step using a condition:
1 | build-docker-image: |
But I think there is a slightly nicer way. We can control the entire job by the needs.package-hash.outputs.tag value and skip it, if the image exists:
1 | name: CI |
Test
Now that we have Docker image build (if needed), let's use it. We can define another job that simply uses the bahmutov/cy:<tag> container, but it cannot depend on the build-docker-image job - if the job is skipped on GitHub Actions, any job that depends on it will be skipped too. Luckily, there is an easy fix: simply have yet another job that will simply "ping" build-docker-image job status. Once the build-docker-image job finishes or is skipped, our "ping" job resolves. The "ping" is done using lewagon/wait-on-check-action 3rd-party action. Here is how the last part of the workflow looks:
1 | name: CI |
The sym linking step is very important. After checkout finishes, the container has the following code
1 | /e2e/ |
The current directory is set to /<folder with checked out source code>, so we need to make sure Node and Cypress can find the DEV dependencies. We do it by creating the symlink:
1 | /e2e/ |
We tell Cypress NPM module to find its binary in the /e2e/cypress_cache/ folder by using the environment variable baked into the Dockerfile: ENV CYPRESS_CACHE_FOLDER=/e2e/cypress_cache.
Run
Let's confirm that it works. We can push the code for the very first time, or change package.json or Dockerfile

The build and push step took 1m14s, and the "ping" job that checked the job status every ten seconds finished in 1m22s. The test job simply pulled the Docker container and ran the specs, without any additional installation or resting a cache.

Pulling the container from the Docker image is by far the longest step in the job.
Now let's push another commit - maybe we changed the specs, but haven't touched the package.json or Dockerfile. The Docker image should have been found.

We start testing pretty quickly, since we don't have to install or build anything.
Cypress Action
Many years ago I wrote cypress-io/github-action, and I am happy to report that it works well with the Docker image with baked DEV dependencies. Here is another workflow job that simply uses the action after sym linking:
1 | test-action: |

Finally, the GitHub Actions summary tells us if the Docker image exists or not based on the checksum.