You don't need QEMU, GitHub Actions, or expensive CI services to build multi-architecture Docker images. With just two VPSs (one cheap ARM machine and one cheap x86 machine) you can build and push native linux/amd64 and linux/arm64 containers without emulation, without performance penalties, and without a monthly surprise bill. My setup costs just €14/month in total and reliably builds for both architectures using Docker's buildx feature.

The key is combining docker buildx with SSH-based Docker contexts: each VPS becomes a node in a custom builder instance. The x86 VPS builds amd64 images locally, while the ARM VPS handles arm64 images natively. BuildKit takes care of the rest: parallel builds, multi-platform manifests, and direct pushes to the registry. It's clean, fast, and yours. No cloud CI, no opaque runners; just you, your servers, and full control over your builds.

Set up the local amd64 docker node:

docker buildx create --name multiarch --use --platform linux/amd64

Set up the remote arm64 docker node:

docker context create arm-vps --docker "host=ssh://[email protected]"

Append the arm64 remote builder:

docker buildx create --append --name multiarch --platform linux/arm64 arm-vps

Inspect:

docker buildx inspect rawpair-builder --bootstrap

Name:          multiarch
Driver:        docker-container
Last Activity: 2025-04-26 16:31:34 +0000 UTC

Nodes:
Name:                  multiarch0
Endpoint:              unix:///var/run/docker.sock
Status:                running
BuildKit daemon flags: --allow-insecure-entitlement=network.host
BuildKit version:      v0.20.2
Platforms:             linux/amd64*, linux/amd64/v2, linux/amd64/v3, linux/amd64/v4, linux/386
Labels:
 org.mobyproject.buildkit.worker.executor:         oci
 org.mobyproject.buildkit.worker.hostname:         baab70c53c2d
 org.mobyproject.buildkit.worker.network:          host
 org.mobyproject.buildkit.worker.oci.process-mode: sandbox
 org.mobyproject.buildkit.worker.selinux.enabled:  false
 org.mobyproject.buildkit.worker.snapshotter:      overlayfs
GC Policy rule#0:
 All:            false
 Filters:        type==source.local,type==exec.cachemount,type==source.git.checkout
 Keep Duration:  48h0m0s
 Max Used Space: 488.3MiB
GC Policy rule#1:
 All:            false
 Keep Duration:  1440h0m0s
 Reserved Space: 9.313GiB
 Max Used Space: 93.13GiB
 Min Free Space: 47.5GiB
GC Policy rule#2:
 All:            false
 Reserved Space: 9.313GiB
 Max Used Space: 93.13GiB
 Min Free Space: 47.5GiB
GC Policy rule#3:
 All:            true
 Reserved Space: 9.313GiB
 Max Used Space: 93.13GiB
 Min Free Space: 47.5GiB

Name:                  multiarch1
Endpoint:              arm-vps
Status:                running
BuildKit daemon flags: --allow-insecure-entitlement=network.host
BuildKit version:      v0.20.2
Platforms:             linux/arm64*, linux/arm/v7, linux/arm/v6
Labels:
 org.mobyproject.buildkit.worker.executor:         oci
 org.mobyproject.buildkit.worker.hostname:         aba4522bbd7d
 org.mobyproject.buildkit.worker.network:          host
 org.mobyproject.buildkit.worker.oci.process-mode: sandbox
 org.mobyproject.buildkit.worker.selinux.enabled:  false
 org.mobyproject.buildkit.worker.snapshotter:      overlayfs
GC Policy rule#0:
 All:            false
 Filters:        type==source.local,type==exec.cachemount,type==source.git.checkout
 Keep Duration:  48h0m0s
 Max Used Space: 488.3MiB
GC Policy rule#1:
 All:            false
 Keep Duration:  1440h0m0s
 Reserved Space: 9.313GiB
 Max Used Space: 93.13GiB
 Min Free Space: 47.5GiB
GC Policy rule#2:
 All:            false
 Reserved Space: 9.313GiB
 Max Used Space: 93.13GiB
 Min Free Space: 47.5GiB
GC Policy rule#3:
 All:            true
 Reserved Space: 9.313GiB
 Max Used Space: 93.13GiB
 Min Free Space: 47.5GiB

As you can see, the local node builds amd64 images, the remote node builds arm64 images.

I hope you find this useful.