Docker 22 🐳 Multi-Stage Pattern: Building Standalone Go, Rust, or Node.js Binaries for Distroless/Scratch
A compiled binary is a promise. It says: everything this program needs is inside this file. No interpreter, no package manager, no shared library. Just instructions the kernel can execute directly. Go and Rust make this promise readily—with the right build flags. Node.js can approximate it with newer tooling that embeds the runtime. Docker’s multi-stage builds turn the promise into an image that contains nothing else: a single binary on a filesystem so empty it has no shell.
This chapter covers the pattern for building standalone binaries and shipping them in scratch or distroless images. You will see the build flags that make a binary static, the base image choices and their trade-offs, and the specific requirements for each language. The result is an image measured in single-digit megabytes that starts in milliseconds and presents almost nothing to an attacker who gains a foothold.
Key point: scratch is an empty filesystem. It contains no shell, no libc, no certificate store, no timezone data, nothing. A binary shipped on scratch must be completely static—no dynamic library dependencies at all. Distroless images are not empty; they include CA certificates, timezone data, and a non-root user, but no shell or package manager.
Why standalone binaries exist
The runtime dependency problem. Most language runtimes—Node.js, Python, Java—require an interpreter to execute. The interpreter must be present in the image, along with its standard library and system dependencies. A Node.js image is at minimum 100–200 MB even before application code is added . Go and Rust compile to machine code, eliminating the interpreter entirely. The resulting binary is the program.
The dynamic linking problem. A binary that links against glibc or libstdc++ at runtime requires those libraries to be present in the image. A Debian base with glibc is around 74 MB. Alpine with musl is around 7 MB, but still larger than empty . Static linking removes the dependency. The binary carries the code it needs, and the image can be empty.
The attack surface problem. Every binary in an image is a potential vulnerability. A shell allows an attacker who gains code execution to run commands, inspect the filesystem, and pivot. curl allows data exfiltration. ls reveals what is present. Removing them does not prevent the initial compromise, but it makes exploitation dramatically harder. Distroless images strip the shell, package manager, and all system utilities .
The performance problem. Image size affects pull time, which affects deployment speed. A 5 MB image pulls in seconds; a 1 GB image can take minutes on cold nodes. In Kubernetes, where pods are rescheduled frequently, the difference compounds. Small images also consume less disk and memory in the registry and on nodes.
The reproducibility problem. A build that runs in a full SDK image and copies artifacts to a minimal image is reproducible in a way that a build on a developer’s laptop is not. The SDK version is pinned in the Dockerfile. The build flags are explicit. The runtime image is declared. The result is deterministic.
a. Building static Go binaries
Go’s standard library includes pure-Go implementations of DNS resolution and user lookup. When CGO_ENABLED=0 is set, the compiler uses these pure-Go paths instead of calling into libc . The resulting binary is fully static—it depends on nothing at runtime except the Linux kernel.
FROM golang:1.25-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o /server ./cmd/server
The build flags do specific work. CGO_ENABLED=0 forces static linking. GOOS=linux ensures a Linux binary even when building on macOS or Windows. -ldflags="-s -w" strips the symbol table (-s) and DWARF debug information (-w), reducing binary size by 20–30% . -trimpath removes absolute paths from the binary for reproducibility .
For the runtime stage, scratch produces the smallest possible image:
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /server /server
USER 65534:65534
ENTRYPOINT ["/server"]
The CA certificates are required for outbound HTTPS. Without them, TLS handshakes fail with opaque errors. The timezone data is required for time.LoadLocation. Both are copied from the build stage because scratch contains neither. The USER directive uses a numeric UID because scratch has no /etc/passwd for name resolution .
Distroless static-debian12 is the pragmatic alternative:
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]
The distroless static image includes CA certificates, timezone data, and a nonroot user by default. It is approximately 2 MB compressed . The image is larger than a bare scratch image but requires no manual file copying and provides a named non-root user.
A Go binary built this way lands between 5 and 15 MB depending on the application and dependencies . The golang:1.26 base image is hundreds of megabytes; the final image is a fraction of that.
b. Building static Rust binaries
Rust’s standard build links against glibc on Linux. A glibc-linked binary will not run on scratch or distroless/static because those images have no libc. The solution is musl, a lightweight libc designed for static linking.
The clux/muslrust image provides a build environment with musl-gcc and the necessary tooling pre-installed . A typical build:
FROM clux/muslrust:stable AS builder
WORKDIR /volume
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && \
cargo build --release --target x86_64-unknown-linux-musl && \
rm -rf src
COPY src ./src
RUN cargo build --release --target x86_64-unknown-linux-musl && \
strip target/x86_64-unknown-linux-musl/release/app
The dummy source file builds dependencies before the real source is copied. This caches the dependency compilation layer; when only application code changes, dependencies are not recompiled . The strip command removes debug symbols from the binary, reducing size by up to 47% .
The runtime stage is the same as Go:
FROM scratch
COPY --from=builder /volume/target/x86_64-unknown-linux-musl/release/app /app
USER 65534:65534
ENTRYPOINT ["/app"]
Rust binaries are larger than Go binaries by default. A simple Rust HTTP service might be 15 MB unstripped, 8 MB stripped . Adding [profile.release] settings to Cargo.toml—lto = true, codegen-units = 1, panic = "abort", opt-level = "z"—reduces size further at the cost of longer build times .
The muslrust image includes some C libraries compiled against musl-gcc, such as SQLite and zlib, enabling static builds when those libraries are used . OpenSSL, curl, and PostgreSQL have been removed from recent versions; projects needing them should use blackdex/rust-musl or similar alternatives .
The muslrust project explicitly recommends distroless static or chainguard static over scratch for production, because those images include non-root users and SSL certificates without manual copying .
c. Building standalone Node.js binaries
Node.js cannot be statically linked in the traditional sense. The runtime depends on libc, libstdc++, and several shared libraries. A fully static Node.js binary is possible to build but not commonly distributed . The modern approach is Node.js Single Executable Applications (SEA), stabilized in recent Node.js versions.
SEA bundles your JavaScript code and its dependencies into a single file using esbuild, then injects that blob into a copy of the Node.js executable using postject . The result is a binary that contains both the runtime and your application. It is not static—it still depends on the host’s libc and libstdc++—but it is self-contained in the sense that no node_modules directory or source files are needed at runtime.
The fossilize tool automates the SEA build process across platforms . A Dockerfile using Node SEA:
FROM node:22 AS builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build
RUN npx fossilize --output /app/server
FROM gcr.io/distroless/nodejs22-debian12
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]
The final image uses distroless Node.js rather than scratch or distroless/static. The Node.js binary requires libc and libstdc++, which distroless/static does not provide. The nodejs22-debian12 distroless image includes the Node.js runtime dependencies but no shell or package manager . The image is approximately 130 MB—larger than Go or Rust images, but smaller than a full node:22 image and with a reduced attack surface .
A Node SEA binary can run on scratch only if it is built against musl and statically linked. The caxa tool supports bundling a musl-linked Node.js binary for Alpine environments, but the process requires downloading a precompiled musl Node.js tarball and ensuring all shared library dependencies are satisfied . For most production use cases, distroless Node.js is the practical choice.
The trade-off is clear. Go and Rust produce binaries that run on empty images because they have no runtime dependencies. Node.js carries its runtime with it, and that runtime has dependencies. SEA reduces the image by eliminating node_modules and source code, but it cannot eliminate libc.
Complete Example Session
# ============================================
# PART 1: GO STATIC BINARY ON SCRATCH
# ============================================
FROM golang:1.25-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o /server ./cmd/server
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /server /server
USER 65534:65534
EXPOSE 8080
ENTRYPOINT ["/server"]
# ============================================
# PART 2: GO ON DISTROLESS
# ============================================
FROM golang:1.25 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /server ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]
# ============================================
# PART 3: RUST STATIC BINARY ON SCRATCH
# ============================================
FROM clux/muslrust:stable AS builder
WORKDIR /volume
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && \
cargo build --release --target x86_64-unknown-linux-musl && \
rm -rf src
COPY src ./src
RUN cargo build --release --target x86_64-unknown-linux-musl && \
strip target/x86_64-unknown-linux-musl/release/app
FROM scratch
COPY --from=builder /volume/target/x86_64-unknown-linux-musl/release/app /app
USER 65534:65534
ENTRYPOINT ["/app"]
# ============================================
# PART 4: RUST WITH DEPENDENCY CACHING
# ============================================
FROM rust:1.75-alpine AS builder
RUN apk add --no-cache musl-dev
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN --mount=type=cache,target=/usr/local/cargo/registry \
mkdir src && echo "fn main() {}" > src/main.rs && \
cargo build --release --target x86_64-unknown-linux-musl && \
rm -rf src
COPY src ./src
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
cargo build --release --target x86_64-unknown-linux-musl && \
strip target/x86_64-unknown-linux-musl/release/app
FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/app /app
USER 1000:1000
ENTRYPOINT ["/app"]
# ============================================
# PART 5: NODE SEA ON DISTROLESS
# ============================================
FROM node:22 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npx fossilize --output /app/server
FROM gcr.io/distroless/nodejs22-debian12
COPY --from=builder /app/server /server
USER nonroot
ENTRYPOINT ["/server"]
# ============================================
# PART 6: SIZE COMPARISON
# ============================================
# Single-stage Go: ~800 MB
# Multi-stage Go scratch: ~10 MB
# Multi-stage Rust scratch: ~8 MB
# Node SEA distroless: ~130 MB
# Full node:22: ~1.1 GB
# ============================================
# PART 7: VERIFYING STATIC LINKING
# ============================================
# ldd target/x86_64-unknown-linux-musl/release/app
# not a dynamic executable
#
# file server
# server: ELF 64-bit LSB executable, statically linked
# ============================================
# PART 8: BUILDING AND MEASURING
# ============================================
# docker build -t myapp:go .
# docker images myapp:go --format '{{.Size}}'
# → 9.2MB
#
# docker build -t myapp:rust .
# docker images myapp:rust --format '{{.Size}}'
# → 7.8MB
# ============================================
# PART 9: NON-ROOT USER IN SCRATCH
# ============================================
FROM scratch
COPY --from=builder /server /server
USER 65534:65534
ENTRYPOINT ["/server"]
# No /etc/passwd, so numeric UID required
# 65534 is the traditional "nobody" UID
# ============================================
# PART 10: MULTI-ARCH BUILD
# ============================================
# docker buildx build --platform linux/amd64,linux/arm64 \
# -t myapp:multi .
The ten parts covered Go static builds, Go on distroless, Rust static builds, Rust with caching, Node SEA, size comparison, verifying static linking, measuring, non-root in scratch, and multi-arch builds.
Quick Reference
Language Build Flags
| Language | Static Build Flag | Size Reduction |
|---|---|---|
| Go | CGO_ENABLED=0 | Required for scratch |
| Go | -ldflags="-s -w" | 20–30% smaller |
| Go | -trimpath | Reproducibility |
| Rust | --target x86_64-unknown-linux-musl | Required for scratch |
| Rust | strip | 47% smaller |
| Node | fossilize or SEA | Self-contained, not static |
Base Image Selection
| Language | Recommended Base | Size | Reason |
|---|---|---|---|
| Go | scratch | 0 MB | Fully static |
| Go | distroless/static | ~2 MB | CA certs, nonroot |
| Rust | scratch | 0 MB | Fully static |
| Rust | distroless/static | ~2 MB | CA certs, nonroot |
| Node | distroless/nodejs | ~130 MB | Runtime required |
| Node | scratch | N/A | Not viable without static Node |
Required Files on Scratch
| File | Purpose | Source |
|---|---|---|
| Binary | The application | Build stage |
ca-certificates.crt | Outbound HTTPS | Build stage |
zoneinfo/ | Timezone data | Build stage |
/etc/passwd | User lookup | Not needed (use numeric UID) |
Distroless Image Sizes
| Image | Size | Use Case |
|---|---|---|
distroless/static | ~2 MB | Go, Rust static |
distroless/base | ~20 MB | CGO, glibc |
distroless/cc | ~25 MB | C/C++ |
distroless/nodejs22 | ~130 MB | Node.js |
distroless/python3 | ~50 MB | Python |
Best Practices
✅ Do This:
# Force static linking for Go
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /server # ✅
# Strip Rust binaries
RUN cargo build --release && strip target/release/app # ✅
# Copy CA certificates to scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ # ✅
# Use numeric UID on scratch
USER 65534:65534 # ✅
# Use distroless for Node.js
FROM gcr.io/distroless/nodejs22-debian12 # ✅
# Cache dependencies before copying source
COPY go.mod go.sum ./
RUN go mod download # ✅
# Verify static linking
RUN ldd /server && echo "not static" || echo "static" # ✅
❌ Don’t Do This:
# Don't use scratch without CGO_ENABLED=0 for Go
FROM scratch # ❌ (binary won't run)
# Don't forget CA certificates for HTTPS
FROM scratch
COPY --from=builder /server /server # ❌ (TLS fails)
# Don't use scratch for Node.js
FROM scratch # ❌ (libc required)
# Don't skip stripping
RUN go build -o /server # ❌ (larger binary)
# Don't use named users on scratch
USER appuser # ❌ (no /etc/passwd)
# Don't copy entire build stage
COPY --from=builder /app /app # ❌ (toolchain included)
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Binary not found | scratch has no shell | Use exec-form ENTRYPOINT |
| TLS handshake fails | Missing CA certificates | Copy ca-certificates.crt |
| Timezone errors | Missing zoneinfo | Copy /usr/share/zoneinfo |
| User lookup fails | No /etc/passwd | Use numeric UID |
| Rust binary won’t run | Linked against glibc | Build with musl target |
| Go binary won’t run | CGO enabled | CGO_ENABLED=0 |
| Node SEA won’t start | Missing libc | Use distroless Node.js |
| Image still large | Copied build stage | Copy only binary |
Real-World Examples
1. Go HTTP Server on Scratch
FROM golang:1.25 AS builder
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /server
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /server /server
ENTRYPOINT ["/server"]
2. Go on Distroless
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]
3. Rust on Scratch
FROM clux/muslrust:stable AS builder
RUN cargo build --release --target x86_64-unknown-linux-musl
RUN strip target/x86_64-unknown-linux-musl/release/app
FROM scratch
COPY --from=builder /volume/target/x86_64-unknown-linux-musl/release/app /app
ENTRYPOINT ["/app"]
4. Rust with LTO
[profile.release]
lto = true
codegen-units = 1
panic = "abort"
strip = true
5. Node SEA
RUN npx fossilize --output /app/server
FROM gcr.io/distroless/nodejs22-debian12
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]
6. Multi-Arch Go
ARG TARGETARCH
RUN CGO_ENABLED=0 GOARCH=${TARGETARCH} go build -o /server
7. Debug Distroless
FROM gcr.io/distroless/static-debian12:debug
# Includes busybox shell for troubleshooting
8. Go with Build Tags
RUN CGO_ENABLED=0 go build -tags netgo,osusergo -o /server
9. Rust Dependency Caching
RUN --mount=type=cache,target=/usr/local/cargo/registry \
cargo build --release
10. Verify Static
ldd target/release/app
# not a dynamic executable
Visual
Image Size Progression
┌─────────────────────────────────────────────────────────────┐
│ GO IMAGE SIZE PROGRESSION │
│ │
│ FROM golang:1.25 ~800 MB │
│ ├── Go toolchain │
│ ├── Standard library source │
│ ├── Debian base │
│ └── Application binary │
│ │
│ Multi-stage + alpine ~15 MB │
│ ├── Alpine base (5 MB) │
│ ├── CA certificates │
│ └── Application binary │
│ │
│ Multi-stage + distroless/static ~2 MB base + binary │
│ ├── CA certificates │
│ ├── Timezone data │
│ ├── Nonroot user │
│ └── Application binary │
│ │
│ Multi-stage + scratch binary only │
│ └── Application binary │
│ │
│ Binary size (stripped): 5–15 MB │
│ Final image: 5–15 MB │
│ │
└─────────────────────────────────────────────────────────────┘
CGO_ENABLED=0 Effect
┌─────────────────────────────────────────────────────────────┐
│ CGO_ENABLED=1 (DEFAULT) │
│ │
│ net.Lookup* ──▶ getaddrinfo (libc) │
│ os/user ──▶ getpwuid (libc) │
│ │
│ Binary: dynamically linked │
│ Requires: libc at runtime │
│ Runs on: glibc images (Debian, Ubuntu) │
│ Does NOT run on: scratch, distroless/static │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ CGO_ENABLED=0 │
│ │
│ net.Lookup* ──▶ Pure-Go DNS resolver │
│ os/user ──▶ Parses /etc/passwd │
│ │
│ Binary: statically linked │
│ Requires: nothing at runtime │
│ Runs on: scratch, distroless/static, any Linux │
│ │
│ Reads /etc/resolv.conf for DNS configuration. │
│ For Kubernetes pods, this is the cluster DNS. │
│ │
└─────────────────────────────────────────────────────────────┘
Scratch Requirements
┌─────────────────────────────────────────────────────────────┐
│ WHAT SCRATCH CONTAINS │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ (empty) │ │
│ │ │ │
│ │ No /bin/sh │ │
│ │ No /bin/ls │ │
│ │ No /etc/passwd │ │
│ │ No libc │ │
│ │ No CA certificates │ │
│ │ No timezone data │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ WHAT YOU MUST ADD │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ /server (the binary) │ │
│ │ /etc/ssl/certs/ (if HTTPS outbound) │ │
│ │ /usr/share/zoneinfo/ (if time.LoadLocation) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ USER 65534:65534 (numeric, no /etc/passwd) │
│ ENTRYPOINT ["/server"] (exec form, no shell) │
│ │
└─────────────────────────────────────────────────────────────┘
Distroless vs Scratch
┌─────────────────────────────────────────────────────────────┐
│ SCRATCH DISTROLESS STATIC │
│ │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ Empty filesystem │ │ Debian base │ │
│ │ │ │ │ │
│ │ + binary │ │ + CA certs │ │
│ │ + CA certs (manual) │ │ + tzdata │ │
│ │ + tzdata (manual) │ │ + nonroot user │ │
│ │ │ │ + binary │ │
│ │ No shell │ │ │ │
│ │ No package manager │ │ No shell │ │
│ │ No users │ │ No package manager │ │
│ └─────────────────────┘ └─────────────────────┘ │
│ │
│ Size: binary only Size: ~2 MB + binary │
│ Setup: manual Setup: copy binary │
│ Debug: impossible Debug: use :debug tag │
│ │
│ distroless is the pragmatic default for most services. │
│ │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Go static flag | CGO_ENABLED=0 |
| Go strip flag | -ldflags="-s -w" |
| Rust static target | x86_64-unknown-linux-musl |
| Rust strip | strip command or Cargo.toml |
| Node standalone | Node SEA / fossilize |
| Scratch base | Empty filesystem |
| Distroless static | ~2 MB, CA certs, nonroot |
| Distroless Node | ~130 MB, runtime included |
| Required on scratch | CA certs, tzdata |
| Non-root on scratch | Numeric UID |
| Go image size | 5–15 MB |
| Rust image size | 5–10 MB |
| Node image size | ~130 MB |
Key takeaways:
- Go and Rust compile to static binaries with the right flags.
CGO_ENABLED=0for Go forces pure-Go implementations of DNS and user lookup, eliminating libc. Rust uses the musl target to link statically. Both produce binaries that run onscratch. scratchis an empty filesystem. The only things in the final image are what youCOPYinto it. CA certificates and timezone data must be added manually if the binary needs them.- Distroless is the pragmatic default. The
static-debian12:nonrootimage includes CA certificates, timezone data, and a nonroot user. It is 2 MB compressed and eliminates the manual copying thatscratchrequires. - Node.js cannot be fully static. The runtime depends on libc. Node SEA bundles the application with the runtime into a single binary, but the binary still needs libc and libstdc++. Distroless Node.js provides these without a shell.
- Strip your binaries.
-ldflags="-s -w"for Go andstripfor Rust remove debug symbols, reducing size by 20–47%. The trade-off is losing symbol-rich debugging against production binaries. - Numeric UIDs work on scratch. Since there is no
/etc/passwd, named users cannot be resolved.USER 65534:65534runs as the traditional “nobody” user without any user database. - CGO affects more than linking. When
CGO_ENABLED=0, Go uses its pure-Go DNS resolver, which reads/etc/resolv.confdirectly. This is the correct behavior in Kubernetes, where cluster DNS configuration lands in that file. - The size reduction is dramatic. A Go image drops from ~800 MB to 5–15 MB. A Rust image lands in the same range. A Node SEA image is ~130 MB—larger, but still a fraction of a full
node:22image and with no shell or package manager.
Remember: A standalone binary is the ideal container artifact. It has no runtime dependencies, no package manager, no shell, and no utilities. The image is the binary, plus whatever data files it genuinely needs. Go and Rust achieve this with build flags. Node.js approximates it with SEA, though the runtime dependency on libc means distroless rather than scratch. The pattern is always the same: build in a full SDK image, copy only the artifact to a minimal base. The final image starts in milliseconds, pulls in seconds, and presents almost nothing to an attacker. The size is a symptom; the reduction in attack surface is the point.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!