diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 773775e816..4aadf4900e 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -28,7 +28,83 @@ concurrency:
   cancel-in-progress: true
 
 jobs:
+  # this cannot run on forks as forks cannot push packages in pull request context
+  # forked pull request will fall back to slow build
+  build-zetanode:
+    runs-on: ubuntu-22.04
+    if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'zeta-chain/node'
+    env:
+      DOCKER_IMAGE: ghcr.io/${{ github.repository_owner }}/zetanode
+      DOCKER_TAG: ${{ github.ref == 'refs/heads/develop' && 'develop' || github.sha }}
+    outputs:
+      image: ${{ fromJson(steps.build.outputs.metadata)['image.name'] }}
+    steps:
+      - uses: actions/checkout@v4
+
+      # configure docker to use the containerd snapshotter
+      # so that we can use the buildkit cache
+      - uses: depot/use-containerd-snapshotter-action@v1
+
+      - name: Login to Docker Hub registry
+        uses: docker/login-action@v3
+        with:
+          username: ${{ secrets.DOCKER_HUB_USERNAME }}
+          password: ${{ secrets.DOCKER_HUB_READ_ONLY }}
+
+      - name: Login to github docker registry
+        uses: docker/login-action@v3
+        with:
+          registry: ghcr.io
+          username: ${{ github.repository_owner }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Restore go cache
+        uses: actions/cache@v4
+        id: restore-go-cache
+        with:
+          path: |
+            go-cache
+          key: cache-${{ hashFiles('go.sum') }}
+
+      - name: Inject go cache into docker
+        uses: reproducible-containers/buildkit-cache-dance@v3.1.2
+        with:
+          cache-map: |
+            {
+              "go-cache": "/root/.cache/go-build"
+            }
+          skip-extraction: ${{ steps.restore-go-cache.outputs.cache-hit || github.event_name != 'push' }}
+
+      # this ensures that the version is consistent between cache build and make build
+      - name: Set version for cache
+        run: |
+          NODE_VERSION=$(./version.sh)
+          echo "NODE_VERSION=$NODE_VERSION" >> $GITHUB_ENV
+          NODE_COMMIT=$(git log -1 --format='%H')
+          echo "NODE_COMMIT=$NODE_COMMIT" >> $GITHUB_ENV
+
+      # build zetanode with cache options
+      - name: Build zetanode for cache
+        id: build
+        uses: docker/build-push-action@v6
+        env:
+          CACHE_FROM_CONFIG: "type=registry,ref=ghcr.io/${{ github.repository }}:buildcache"
+          CACHE_TO_CONFIG: "type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max"
+        with:
+          context: .
+          file: ./Dockerfile-localnet
+          push: true
+          tags: ${{ env.DOCKER_IMAGE }}:${{ env.DOCKER_TAG }}
+          cache-from: ${{ env.CACHE_FROM_CONFIG }}
+          cache-to: ${{ github.event_name == 'push' && env.CACHE_TO_CONFIG || '' }}
+          target: latest-runtime
+          build-args: |
+            NODE_VERSION=${{ env.NODE_VERSION }}
+            NODE_COMMIT=${{ env.NODE_COMMIT }}
+
   matrix-conditionals:
+    needs: build-zetanode
+    if: always()
     runs-on: ubuntu-22.04
     env:
       GH_TOKEN: ${{ github.token }}
@@ -119,7 +195,10 @@ jobs:
             }
 
   e2e:
-    needs: matrix-conditionals
+    needs:
+      - build-zetanode
+      - matrix-conditionals
+    if: always()
     strategy:
       fail-fast: false
       matrix:
@@ -170,12 +249,14 @@ jobs:
       runs-on: ${{ matrix.runs-on}}
       run: ${{ matrix.run }}
       timeout-minutes: "${{ matrix.timeout-minutes || 25 }}"
+      zetanode-image: ${{ needs.build-zetanode.outputs.image }}
       enable-monitoring: ${{ needs.matrix-conditionals.outputs.ENABLE_MONITORING == 'true' }}
     secrets: inherit
   # this allows you to set a required status check
   e2e-ok:
     runs-on: ubuntu-22.04
     needs:
+      - build-zetanode
       - matrix-conditionals
       - e2e
     if: always()
@@ -224,6 +305,10 @@ jobs:
 
 
       - run: |
+          result="${{ needs.build-zetanode.result }}"
+          if [[ $result == "failed" ]]; then
+            exit 1
+          fi
           result="${{ needs.e2e.result }}"
           if [[ $result == "success" || $result == "skipped" ]]; then
             exit 0
diff --git a/.github/workflows/reusable-e2e.yml b/.github/workflows/reusable-e2e.yml
index 88c42466a5..a4aa35c2ec 100644
--- a/.github/workflows/reusable-e2e.yml
+++ b/.github/workflows/reusable-e2e.yml
@@ -19,6 +19,10 @@ on:
         required: true
         type: string
         default: 'ubuntu-20.04'
+      zetanode-image:
+        description: 'docker image to use for zetanode'
+        required: true
+        type: string
       enable-monitoring:
         description: 'Enable the monitoring stack for this run'
         type: boolean
@@ -31,12 +35,10 @@ jobs:
     timeout-minutes: ${{ inputs.timeout-minutes }}
     strategy:
       fail-fast: false
+    env:
+      ZETANODE_IMAGE: ${{ inputs.zetanode-image }}
     steps:
       - uses: actions/checkout@v4
-      
-      # configure docker to use the containerd snapshotter
-      # so that we can use the buildkit cache
-      - uses: depot/use-containerd-snapshotter-action@v1
 
       - name: Login to Docker Hub registry
         uses: docker/login-action@v3
@@ -51,50 +53,7 @@ jobs:
           registry: ghcr.io
           username: ${{ github.repository_owner }}
           password: ${{ secrets.GITHUB_TOKEN }}
-
-      - name: Restore go cache
-        uses: actions/cache@v4
-        id: restore-go-cache
-        with:
-          path: |
-            go-cache
-          key: cache-${{ hashFiles('go.sum') }}
-
-      - name: Inject go cache into docker
-        uses: reproducible-containers/buildkit-cache-dance@v3.1.2
-        with:
-          cache-map: |
-            {
-              "go-cache": "/root/.cache/go-build"
-            }
-          skip-extraction: ${{ steps.restore-go-cache.outputs.cache-hit || github.event_name != 'push' }}
-
-      # this ensures that the version is consistent between cache build and make build
-      - name: Set version for cache
-        run: |
-          NODE_VERSION=$(./version.sh)
-          echo "NODE_VERSION=$NODE_VERSION" >> $GITHUB_ENV
-          NODE_COMMIT=$(git log -1 --format='%H')
-          echo "NODE_COMMIT=$NODE_COMMIT" >> $GITHUB_ENV
-
-      # build zetanode with cache options
-      - name: Build zetanode for cache
-        uses: docker/build-push-action@v6
-        env:
-          CACHE_FROM_CONFIG: "type=registry,ref=ghcr.io/${{ github.repository }}:buildcache"
-          CACHE_TO_CONFIG: "type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max"
-        with:
-          context: .
-          file: ./Dockerfile-localnet
-          push: false
-          tags: zetanode:latest
-          cache-from: ${{ env.CACHE_FROM_CONFIG }}
-          cache-to: ${{ github.event_name == 'push' && env.CACHE_TO_CONFIG || '' }}
-          target: latest-runtime
-          build-args: |
-            NODE_VERSION=${{ env.NODE_VERSION }}
-            NODE_COMMIT=${{ env.NODE_COMMIT }}
-
+      
       - name: Enable monitoring
         if: inputs.enable-monitoring
         run: |
diff --git a/Dockerfile-localnet b/Dockerfile-localnet
index 49247d6be4..09e8c15a10 100644
--- a/Dockerfile-localnet
+++ b/Dockerfile-localnet
@@ -67,7 +67,8 @@ COPY --from=latest-build /go/bin/zetacored /go/bin/zetaclientd /go/bin/zetaclien
 
 # Optional old version build (from source). This old build is used as the genesis version in the upgrade tests. 
 # Use --target latest-runtime to skip.
-FROM base-build AS old-build-source
+# you must have already built the latest image (which the Makefile does)
+FROM zetanode:latest AS old-build-source
 
 ARG OLD_VERSION
 RUN git clone https://github.com/zeta-chain/node.git
@@ -84,13 +85,12 @@ COPY --from=latest-build /go/bin/zetaclientd-supervisor /usr/local/bin
 
 # Optional old version build (from binary).
 # Use --target latest-runtime to skip.
-FROM base-runtime AS old-runtime
+# you must have already built the latest image (which the Makefile does)
+FROM zetanode:latest AS old-runtime
 
 ARG OLD_VERSION
 ARG BUILDARCH
 
-COPY --from=cosmovisor-build /go/bin/cosmovisor /usr/local/bin
-COPY --from=latest-build /go/bin/zetaclientd-supervisor /usr/local/bin
 RUN curl -Lo /usr/local/bin/zetacored ${OLD_VERSION}/zetacored-linux-${BUILDARCH} && \
     chmod 755 /usr/local/bin/zetacored && \
     curl -Lo /usr/local/bin/zetaclientd ${OLD_VERSION}/zetaclientd-linux-${BUILDARCH} && \
diff --git a/Makefile b/Makefile
index cb191cecb6..38cd6d3be7 100644
--- a/Makefile
+++ b/Makefile
@@ -223,7 +223,8 @@ generate: proto-gen openapi specs typescript docs-zetacored mocks precompiles fm
 ###############################################################################
 ###                         Localnet                          				###
 ###############################################################################
-start-localnet: zetanode start-localnet-skip-build
+e2e-images: zetanode orchestrator
+start-localnet: e2e-images start-localnet-skip-build
 
 start-localnet-skip-build:
 	@echo "--> Starting localnet"
@@ -238,11 +239,23 @@ stop-localnet:
 ###                         E2E tests               						###
 ###############################################################################
 
+ifdef ZETANODE_IMAGE
+zetanode:
+	@echo "Pulling zetanode image"
+	$(DOCKER) pull $(ZETANODE_IMAGE)
+	$(DOCKER) tag $(ZETANODE_IMAGE) zetanode:latest
+.PHONY: zetanode
+else
 zetanode:
 	@echo "Building zetanode"
 	$(DOCKER) build -t zetanode --build-arg NODE_VERSION=$(NODE_VERSION) --build-arg NODE_COMMIT=$(NODE_COMMIT) --target latest-runtime -f ./Dockerfile-localnet .
-	$(DOCKER) build -t orchestrator -f contrib/localnet/orchestrator/Dockerfile.fastbuild .
 .PHONY: zetanode
+endif
+
+orchestrator:
+	@echo "Building e2e orchestrator"
+	$(DOCKER) build -t orchestrator -f contrib/localnet/orchestrator/Dockerfile.fastbuild .
+.PHONY: orchestrator
 
 install-zetae2e: go.sum
 	@echo "--> Installing zetae2e"
@@ -253,47 +266,47 @@ solana:
 	@echo "Building solana docker image"
 	$(DOCKER) build -t solana-local -f contrib/localnet/solana/Dockerfile contrib/localnet/solana/
 
-start-e2e-test: zetanode
+start-e2e-test: e2e-images
 	@echo "--> Starting e2e test"
 	cd contrib/localnet/ && $(DOCKER_COMPOSE) up -d 
 
-start-e2e-admin-test: zetanode
+start-e2e-admin-test: e2e-images
 	@echo "--> Starting e2e admin test"
 	export E2E_ARGS="--skip-regular --test-admin" && \
 	cd contrib/localnet/ && $(DOCKER_COMPOSE) --profile eth2 up -d
 
-start-e2e-performance-test: zetanode
+start-e2e-performance-test: e2e-images
 	@echo "--> Starting e2e performance test"
 	export E2E_ARGS="--test-performance" && \
 	cd contrib/localnet/ && $(DOCKER_COMPOSE) --profile stress up -d
 
-start-e2e-import-mainnet-test: zetanode
+start-e2e-import-mainnet-test: e2e-images
 	@echo "--> Starting e2e import-data test"
 	export ZETACORED_IMPORT_GENESIS_DATA=true && \
 	export ZETACORED_START_PERIOD=15m && \
 	cd contrib/localnet/ && ./scripts/import-data.sh mainnet && $(DOCKER_COMPOSE) up -d
 
-start-stress-test: zetanode
+start-stress-test: e2e-images
 	@echo "--> Starting stress test"
 	cd contrib/localnet/ && $(DOCKER_COMPOSE) --profile stress up -d
 
-start-tss-migration-test: zetanode
+start-tss-migration-test: e2e-images
 	@echo "--> Starting tss migration test"
 	export LOCALNET_MODE=tss-migrate && \
 	export E2E_ARGS="--test-tss-migration" && \
 	cd contrib/localnet/ && $(DOCKER_COMPOSE) up -d
 
-start-solana-test: zetanode solana
+start-solana-test: e2e-images solana
 	@echo "--> Starting solana test"
 	export E2E_ARGS="--skip-regular --test-solana" && \
 	cd contrib/localnet/ && $(DOCKER_COMPOSE) --profile solana up -d
 
-start-ton-test: zetanode
+start-ton-test: e2e-images
 	@echo "--> Starting TON test"
 	export E2E_ARGS="--skip-regular --test-ton" && \
 	cd contrib/localnet/ && $(DOCKER_COMPOSE) --profile ton up -d
 
-start-v2-test: zetanode
+start-v2-test: e2e-images
 	@echo "--> Starting e2e smart contracts v2 test"
 	export E2E_ARGS="--skip-regular --test-v2" && \
 	cd contrib/localnet/ && $(DOCKER_COMPOSE) up -d
@@ -305,7 +318,7 @@ start-v2-test: zetanode
 # build from source only if requested
 # NODE_VERSION and NODE_COMMIT must be set as old-runtime depends on lastest-runtime
 ifdef UPGRADE_TEST_FROM_SOURCE
-zetanode-upgrade: zetanode
+zetanode-upgrade: e2e-images
 	@echo "Building zetanode-upgrade from source"
 	$(DOCKER) build -t zetanode:old -f Dockerfile-localnet --target old-runtime-source \
 		--build-arg OLD_VERSION='release/v21' \
@@ -314,7 +327,7 @@ zetanode-upgrade: zetanode
 		.
 .PHONY: zetanode-upgrade
 else
-zetanode-upgrade: zetanode
+zetanode-upgrade: e2e-images
 	@echo "Building zetanode-upgrade from binaries"
 	$(DOCKER) build -t zetanode:old -f Dockerfile-localnet --target old-runtime \
 	--build-arg OLD_VERSION='https://github.com/zeta-chain/node/releases/download/v21.0.0' \
diff --git a/contrib/localnet/orchestrator/Dockerfile.fastbuild b/contrib/localnet/orchestrator/Dockerfile.fastbuild
index cbab881a65..308e59a146 100644
--- a/contrib/localnet/orchestrator/Dockerfile.fastbuild
+++ b/contrib/localnet/orchestrator/Dockerfile.fastbuild
@@ -1,17 +1,11 @@
 # syntax=ghcr.io/zeta-chain/docker-dockerfile:1.9-labs
 # check=error=true
-FROM zetanode:latest AS zeta
 FROM ghcr.io/zeta-chain/ethereum-client-go:v1.10.26 AS geth
 FROM ghcr.io/zeta-chain/solana-docker:1.18.15 AS solana
-FROM ghcr.io/zeta-chain/golang:1.22.7-bookworm AS orchestrator
-
-RUN apt update && \
-    apt install -yq jq yq curl tmux python3 openssh-server iputils-ping iproute2 bind9-host && \
-    rm -rf /var/lib/apt/lists/*
+FROM zetanode:latest
 
 COPY --from=geth /usr/local/bin/geth /usr/local/bin/
 COPY --from=solana /usr/bin/solana /usr/local/bin/
-COPY --from=zeta /usr/local/bin/zetacored /usr/local/bin/zetaclientd /usr/local/bin/zetae2e /usr/local/bin/
 
 COPY contrib/localnet/orchestrator/start-zetae2e.sh /work/
 COPY contrib/localnet/scripts/wait-for-ton.sh /work/