How to port your application to ARM64 (aarch64)
To port an application to ARM64 you usually change less than you fear. This guide goes language by language through what moving to an aarch64 VPS involves: for interpreted languages, often nothing; for compiled ones, a different build target; and for C and C++, a short list of real traps worth knowing about.
If you have not yet decided whether ARM suits your service at all, start with is ARM good for servers? and the five-minute check on our ARM page.
The easiest option: build on the ARM server
Everything below about cross-compiling is optional. An ARM64 Linux server is a normal Linux machine with a normal compiler. Clone your repository onto it, run your usual build, and the output is native ARM64. Cross-compiling earns its keep in CI, where you want both architectures from one x86 runner, but for a first port, building on the target is the shortest path to finding out what breaks.
PHP, Python, Node.js and Ruby
Your source code does not change. The interpreter is ARM64, and it runs the same scripts. What can differ is the compiled code underneath:
-
Python: pip installs prebuilt wheels when one exists for the platform, tagged
manylinux_..._aarch64. Most popular packages publish them. You can check a whole requirements file from any machine before you move:pip download -r requirements.txt --only-binary=:all: \ --platform manylinux2014_aarch64 --platform manylinux_2_28_aarch64 \ --python-version 3.12 -d /tmp/wheelsUse the Python version you will actually run. A package this reports as missing is not fatal: pip will build it from source on the server, which needs a compiler and that library's headers there.
-
Node.js: native addons are built by node-gyp or shipped as prebuilt binaries per platform. Never copy
node_modulesfrom an x86 machine; runnpm cion the ARM server so each addon fetches or builds its ARM64 version. -
PHP: extensions come from your distribution's packages or PECL, both of which build for arm64. The thing to watch is a commercial extension or encoded code that needs a vendor-supplied loader: check the vendor ships an ARM64 Linux build.
-
Ruby: gems with C extensions compile on install, so
bundle installon the server does the right thing.
Go
Go cross-compiles from any machine with two environment variables:
GOOS=linux GOARCH=arm64 go build -o myapp ./cmd/myapp
That is the whole port for a pure Go program. If you use cgo, you also need a C
cross-compiler (CGO_ENABLED=1 CC=aarch64-linux-gnu-gcc), or you build on an
ARM64 machine instead.
Rust
Add the target, then build for it:
rustup target add aarch64-unknown-linux-gnu
cargo build --release --target aarch64-unknown-linux-gnu
Cargo needs a linker for the target. Install gcc-aarch64-linux-gnu and set
linker = "aarch64-linux-gnu-gcc" under [target.aarch64-unknown-linux-gnu] in
.cargo/config.toml, or use the cross tool, which runs the build in a
container that already has the toolchain.
Java and .NET
The runtime does the work. Install an ARM64 build of the JVM (Eclipse Temurin,
Amazon Corretto and the distribution OpenJDK packages all ship one) or of .NET,
and the same .jar or the same IL runs unchanged.
What to check is native code hidden inside dependencies:
- Java: libraries that use JNI bundle a native library per platform inside
the jar. Look for a
linux-aarch64(sometimeslinux-arm64) build in the version you use. Older versions of some libraries predate ARM support, so the fix is often just an upgrade. - .NET: publish for the right runtime identifier,
dotnet publish -r linux-arm64, and check that NuGet packages with native parts includeruntimes/linux-arm64.
C and C++: the real traps
C and C++ are where porting is real work, and it is still mostly a rebuild. On
Debian or Ubuntu, gcc-aarch64-linux-gnu and g++-aarch64-linux-gnu give you a
cross-compiler; autotools takes --host=aarch64-linux-gnu, and CMake takes a
toolchain file. The traps are in the code, not the build:
charis unsigned on ARM64 Linux and signed on x86-64. Code that stores a negative value in a plaincharand compares it behaves differently. GCC's-fsigned-charrestores x86 behaviour while you fix it properly.- The memory model is weaker. x86 guarantees an ordering of memory operations that ARM does not. Code with a data race, or a hand-rolled lock-free structure that leaned on x86 ordering, can fail on ARM after years of apparently working. Use the C11 or C++11 atomics with explicit orderings, and run ThreadSanitizer.
- SIMD intrinsics do not translate. SSE and AVX intrinsics need an ARM NEON equivalent. Libraries such as SIMDe and sse2neon map many of them for you; a portable library such as Highway avoids the problem in new code.
- Atomics performance. Neoverse N1 cores, which Ampere Altra uses, support
the Armv8.1 LSE atomic instructions. Recent GCC and Clang emit code that uses
them when the CPU has them (
-moutline-atomics). For a build that only ever runs on this class of server,-mcpu=neoverse-n1targets it directly, at the cost of not running on older ARMv8.0 chips such as the Raspberry Pi 4. - Page size assumptions. Most arm64 distributions use 4 KB pages, like x86, but some kernels use 64 KB. Software that hard-codes a page size at build time (some builds of the jemalloc allocator do) fails on a kernel with a larger one.
Test it where it will run
An emulator tells you a program starts; it does not tell you it is correct. Run your test suite on real ARM64 hardware before you move production: an ARM server, an Apple Silicon Mac with a Linux VM, or a CI runner on ARM. A test that only fails on ARM is almost always one of the C and C++ traps above.
When it passes, the rest of the move is ordinary. If you run containers, the next step is building multi-arch Docker images. If you want to talk through a port before committing to it, get in touch: VPS GB is ARM-only, so this is the question we answer most.
