How Multi-Stage Docker Builds Reduce Image Size by 80%

Multi-stage Docker builds eliminate build dependencies from production images, cutting sizes from gigabytes to megabytes. Here's the complete guide with...

T
TechSaaS Team
12 min read

The 1.2 GB Container Problem

You write a 200-line Node.js API. You build a Docker image. It's 1.2 GB.

<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 200" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="200" rx="12" fill="#1a1a2e"/><rect x="30" y="30" width="100" height="130" rx="6" fill="none" stroke="#3b82f6" stroke-width="1.5"/><text x="80" y="55" text-anchor="middle" fill="#3b82f6" font-size="10" font-family="monospace">docker-</text><text x="80" y="70" text-anchor="middle" fill="#3b82f6" font-size="10" font-family="monospace">compose</text><text x="80" y="85" text-anchor="middle" fill="#3b82f6" font-size="10" font-family="monospace">.yml</text><line x1="45" y1="95" x2="115" y2="95" stroke="#3b82f6" stroke-width="0.5" opacity="0.5"/><rect x="50" y="105" width="50" height="8" rx="2" fill="#94a3b8" opacity="0.3"/><rect x="50" y="118" width="60" height="8" rx="2" fill="#94a3b8" opacity="0.3"/><rect x="50" y="131" width="40" height="8" rx="2" fill="#94a3b8" opacity="0.3"/><path d="M135,95 L175,95" stroke="#e2e8f0" stroke-width="2" marker-end="url(#arrow2)"/><defs><marker id="arrow2" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6" fill="#e2e8f0"/></marker></defs><rect x="180" y="20" width="130" height="35" rx="6" fill="#6366f1" opacity="0.85"/><text x="245" y="42" text-anchor="middle" fill="#ffffff" font-size="11" font-family="system-ui">Web App</text><rect x="180" y="62" width="130" height="35" rx="6" fill="#a855f7" opacity="0.85"/><text x="245" y="84" text-anchor="middle" fill="#ffffff" font-size="11" font-family="system-ui">API Server</text><rect x="180" y="104" width="130" height="35" rx="6" fill="#2dd4bf" opacity="0.85"/><text x="245" y="126" text-anchor="middle" fill="#1a1a2e" font-size="11" font-family="system-ui">Database</text><rect x="180" y="146" width="130" height="35" rx="6" fill="#f59e0b" opacity="0.85"/><text x="245" y="168" text-anchor="middle" fill="#1a1a2e" font-size="11" font-family="system-ui">Cache</text><rect x="370" y="40" width="200" height="130" rx="8" fill="none" stroke="#e2e8f0" stroke-width="1" stroke-dasharray="5,4"/><text x="470" y="62" text-anchor="middle" fill="#e2e8f0" font-size="10" font-family="system-ui">Docker Network</text><line x1="310" y1="37" x2="390" y2="80" stroke="#94a3b8" stroke-width="1" opacity="0.5"/><line x1="310" y1="79" x2="390" y2="100" stroke="#94a3b8" stroke-width="1" opacity="0.5"/><line x1="310" y1="121" x2="390" y2="120" stroke="#94a3b8" stroke-width="1" opacity="0.5"/><line x1="310" y1="163" x2="390" y2="140" stroke="#94a3b8" stroke-width="1" opacity="0.5"/><circle cx="400" cy="80" r="5" fill="#6366f1"/><circle cx="400" cy="100" r="5" fill="#a855f7"/><circle cx="400" cy="120" r="5" fill="#2dd4bf"/><circle cx="400" cy="140" r="5" fill="#f59e0b"/><text x="470" y="85" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">:3000</text><text x="470" y="105" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">:8080</text><text x="470" y="125" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">:5432</text><text x="470" y="145" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">:6379</text></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">Docker Compose defines your entire application stack in a single YAML file.</p></div>

Inside that image: GCC, Python build tools, node-gyp, thousands of header files, development dependencies, the entire npm cache, and your actual application code — about 15 MB of it.

This isn't hypothetical. It's the default outcome when you use a standard Dockerfile with a single FROM instruction. Every RUN command that installs build tools, every npm install that pulls devDependencies, every apt-get that adds compilation libraries — they all become permanent layers in your production image.

Multi-stage builds fix this completely. The same application ships as a 150 MB image. Same functionality, same runtime behavior, 80% smaller.

How Multi-Stage Builds Work

A multi-stage Dockerfile uses multiple FROM instructions. Each FROM starts a new build stage with a fresh filesystem. The critical insight: you can COPY --from= a previous stage, selectively pulling only the artifacts you need into the final image.

# Stage 1: Build environment (heavy, temporary)
FROM node:20-bookworm AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Production environment (minimal, permanent)
FROM node:20-bookworm-slim AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
EXPOSE 3000
CMD ["node", "dist/index.js"]

Stage 1 has everything needed to build: full Node.js, npm, build tools, devDependencies. Stage 2 has only what's needed to run: a slim Node.js runtime, production code, and production dependencies.

The build tools, source code, devDependencies, and npm cache from Stage 1 never make it into the final image. They exist only during the build process.

Real-World Examples

Node.js / TypeScript API

# ---- Build Stage ----
FROM node:20-bookworm AS builder
WORKDIR /app

# Install dependencies first (better layer caching)
COPY package.json package-lock.json ./
RUN npm ci --include=dev

# Copy source and build
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build

# Prune devDependencies
RUN npm prune --production

# ---- Production Stage ----
FROM node:20-bookworm-slim
WORKDIR /app

# Non-root user for security
RUN groupadd -r appuser && useradd -r -g appuser appuser

# Copy only production artifacts
COPY --from=builder --chown=appuser:appuser /app/dist ./dist
COPY --from=builder --chown=appuser:appuser /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appuser /app/package.json ./

USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"
CMD ["node", "dist/index.js"]

Size comparison:

Stage
Image
Size

|-------|-------|------|

Single-stage (node:20)
Everything included
1.1 GB
Multi-stage (node:20-slim)
Production only
210 MB
Multi-stage (distroless)
Minimal runtime
130 MB

Key techniques used:

npm ci --include=dev in build stage for reproducible installs
npm prune --production removes devDependencies before copying to production
node:20-bookworm-slim is 200 MB smaller than the full image
Non-root user for container security
Health check for orchestrator integration

Go Service

Go produces static binaries, making multi-stage builds even more effective:

# ---- Build Stage ----
FROM golang:1.22-bookworm AS builder
WORKDIR /app

# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download

# Build static binary
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -ldflags='-w -s -extldflags "-static"' \
    -o /app/server ./cmd/server

# ---- Production Stage ----
FROM scratch

# Copy CA certificates for HTTPS
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

# Copy the static binary
COPY --from=builder /app/server /server

EXPOSE 8080
ENTRYPOINT ["/server"]

Size comparison:

Stage
Image
Size

|-------|-------|------|

Single-stage (golang:1.22)
Full Go toolchain
850 MB
Multi-stage (alpine)
Alpine + binary
25 MB
Multi-stage (scratch)
Just the binary
12 MB

The scratch base image is literally empty — zero bytes. The final image contains only your compiled binary and CA certificates. From 850 MB to 12 MB is a 98.6% reduction.

Build flags explained:

CGO_ENABLED=0: Disable C bindings for a pure Go binary
-ldflags='-w -s': Strip debug symbols and DWARF tables
-extldflags "-static": Ensure fully static linking

Rust Service

# ---- Build Stage ----
FROM rust:1.76-bookworm AS builder
WORKDIR /app

# Cache dependencies with a dummy build
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo 'fn main() {}' > src/main.rs
RUN cargo build --release && rm -rf src target/release/deps/myapp*

# Build the actual application
COPY src/ ./src/
RUN cargo build --release

# ---- Production Stage ----
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/myapp /usr/local/bin/
EXPOSE 3000
CMD ["myapp"]

Size comparison:

Stage
Image
Size

|-------|-------|------|

Single-stage (rust:1.76)
Full toolchain
1.4 GB
Multi-stage (debian-slim)
Runtime + binary
85 MB
Multi-stage (scratch with musl)
Just the binary
8 MB

The dummy build trick (echo 'fn main() {}' > src/main.rs) caches dependency compilation. When your source code changes, only your code recompiles — not all 200 crate dependencies.

Python (FastAPI)

Python is trickier because it's interpreted, but multi-stage builds still help significantly:

# ---- Build Stage ----
FROM python:3.12-bookworm AS builder
WORKDIR /app

# Install build dependencies for compiled packages
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc libpq-dev && rm -rf /var/lib/apt/lists/*

# Create virtual environment and install dependencies
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

# ---- Production Stage ----
FROM python:3.12-slim-bookworm
WORKDIR /app

# Install only runtime libraries (no gcc, no headers)
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq5 && rm -rf /var/lib/apt/lists/*

# Copy the virtual environment from builder
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

# Copy application code
COPY app/ ./app/

RUN useradd -r appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Size comparison:

Stage
Image
Size

|-------|-------|------|

Single-stage (python:3.12)
Full Python + build tools
1.0 GB
Multi-stage (python:3.12-slim)
Slim Python + venv
180 MB

The key insight: copy the entire virtual environment (/opt/venv) from the build stage. The build stage has GCC and header files for compiling C extensions (psycopg2, numpy, etc.), but the production stage only needs the compiled .so files and pure Python packages.

Java (Spring Boot)

# ---- Build Stage ----
FROM eclipse-temurin:21-jdk-jammy AS builder
WORKDIR /app
COPY gradle/ gradle/
COPY gradlew build.gradle.kts settings.gradle.kts ./
RUN ./gradlew dependencies --no-daemon
COPY src/ ./src/
RUN ./gradlew bootJar --no-daemon

# ---- Extract layers for better caching ----
FROM eclipse-temurin:21-jdk-jammy AS extractor
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract

# ---- Production Stage ----
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
RUN groupadd -r appuser && useradd -r -g appuser appuser
COPY --from=extractor /app/dependencies/ ./
COPY --from=extractor /app/spring-boot-loader/ ./
COPY --from=extractor /app/snapshot-dependencies/ ./
COPY --from=extractor /app/application/ ./
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

This uses three stages: build, extract layers, and production. Spring Boot's layertools extract dependencies into separate directories, enabling better Docker layer caching — library dependencies change less often than application code.

Advanced Techniques

Build Arguments for Conditional Stages

ARG BUILD_ENV=production

# Base build
FROM node:20-bookworm AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .

# Development stage (includes devDeps, source maps, hot reload)
FROM base AS development
RUN npm install --include=dev
CMD ["npm", "run", "dev"]

# Test stage (runs tests then exits)
FROM base AS test
RUN npm install --include=dev
RUN npm test

# Production build
FROM base AS build
RUN npm run build
RUN npm prune --production

# Production image
FROM node:20-bookworm-slim AS production
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
CMD ["node", "dist/index.js"]

Build specific stages with --target:

# Development
docker build --target development -t myapp:dev .

# Run tests (fails if tests fail)
docker build --target test -t myapp:test .

# Production
docker build --target production -t myapp:prod .

External Images as Stages

You can copy from any image, not just previous stages:

FROM alpine:3.19

# Copy a specific binary from an external image
COPY --from=docker.io/bitnami/kubectl:1.29 /opt/bitnami/kubectl/bin/kubectl /usr/local/bin/
COPY --from=docker.io/aquasec/trivy:latest /usr/local/bin/trivy /usr/local/bin/

# Your application
COPY --from=builder /app/deploy.sh /usr/local/bin/
CMD ["deploy.sh"]

This creates deployment tooling images without installing anything from package managers.

<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 220" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="220" rx="12" fill="#1a1a2e"/><rect x="200" y="15" width="200" height="40" rx="8" fill="#6366f1"/><text x="300" y="40" text-anchor="middle" fill="#ffffff" font-size="13" font-family="system-ui" font-weight="bold">Orchestrator</text><line x1="250" y1="55" x2="100" y2="90" stroke="#e2e8f0" stroke-width="1.5" stroke-dasharray="4,3"/><line x1="300" y1="55" x2="300" y2="90" stroke="#e2e8f0" stroke-width="1.5" stroke-dasharray="4,3"/><line x1="350" y1="55" x2="500" y2="90" stroke="#e2e8f0" stroke-width="1.5" stroke-dasharray="4,3"/><rect x="40" y="90" width="120" height="110" rx="8" fill="none" stroke="#3b82f6" stroke-width="1.5"/><text x="100" y="110" text-anchor="middle" fill="#3b82f6" font-size="11" font-family="system-ui">Node 1</text><rect x="55" y="120" width="90" height="25" rx="4" fill="#6366f1" opacity="0.7"/><text x="100" y="137" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Container A</text><rect x="55" y="150" width="90" height="25" rx="4" fill="#a855f7" opacity="0.7"/><text x="100" y="167" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Container B</text><rect x="240" y="90" width="120" height="110" rx="8" fill="none" stroke="#3b82f6" stroke-width="1.5"/><text x="300" y="110" text-anchor="middle" fill="#3b82f6" font-size="11" font-family="system-ui">Node 2</text><rect x="255" y="120" width="90" height="25" rx="4" fill="#2dd4bf" opacity="0.7"/><text x="300" y="137" text-anchor="middle" fill="#1a1a2e" font-size="10" font-family="system-ui">Container C</text><rect x="255" y="150" width="90" height="25" rx="4" fill="#6366f1" opacity="0.7"/><text x="300" y="167" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Container A</text><rect x="440" y="90" width="120" height="110" rx="8" fill="none" stroke="#3b82f6" stroke-width="1.5"/><text x="500" y="110" text-anchor="middle" fill="#3b82f6" font-size="11" font-family="system-ui">Node 3</text><rect x="455" y="120" width="90" height="25" rx="4" fill="#a855f7" opacity="0.7"/><text x="500" y="137" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Container B</text><rect x="455" y="150" width="90" height="25" rx="4" fill="#f59e0b" opacity="0.7"/><text x="500" y="167" text-anchor="middle" fill="#1a1a2e" font-size="10" font-family="system-ui">Container D</text></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">Container orchestration distributes workloads across multiple nodes for resilience and scale.</p></div>

BuildKit Cache Mounts

Docker BuildKit enables cache mounts that persist across builds:

# syntax=docker/dockerfile:1
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./

# Cache the Go module download
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download

COPY . .

# Cache the Go build cache
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 go build -o /app/server ./cmd/server

FROM scratch
COPY --from=builder /app/server /server
CMD ["/server"]

Cache mounts don't become image layers — they persist on the build host between builds. This dramatically speeds up rebuilds without increasing image size.

CI/CD Integration

GitHub Actions

name: Build and Push
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          target: production

The cache-from and cache-to with type=gha uses GitHub Actions' cache backend for Docker layer caching. Combined with multi-stage builds, rebuilds that only change application code complete in seconds.

Gitea Actions / Self-Hosted CI

name: Build
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build production image
        run: |
          docker build \
            --target production \
            --cache-from myregistry.com/myapp:cache \
            -t myregistry.com/myapp:${{ github.sha }} \
            -t myregistry.com/myapp:latest .

      - name: Run tests in container
        run: |
          docker build --target test .

      - name: Push
        run: |
          docker push myregistry.com/myapp:${{ github.sha }}
          docker push myregistry.com/myapp:latest

Security Benefits

Multi-stage builds aren't just about size. They're a security practice:

1. Smaller attack surface: No GCC, no make, no package managers in production. Fewer binaries = fewer potential exploits.

2. No build secrets in production: Build-time secrets (npm tokens, private registry credentials) exist only in the build stage. They never make it to the final image.

# Build stage — secret is available here only
FROM node:20 AS builder
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci

# Production — no trace of the secret
FROM node:20-slim
COPY --from=builder /app/node_modules ./node_modules

3. Vulnerability reduction: Scanning a 1.2 GB image with Trivy might flag 200+ CVEs in build tools you don't need. A 150 MB slim image might have 15. Fewer packages = fewer CVEs to triage.

4. Immutable production images: The production stage contains exactly what's needed. No package managers to install additional software. No compilers to build exploit tools.

Common Mistakes

Mistake 1: Copying Too Much

# Bad: copies everything including node_modules from host
COPY . .

# Good: use .dockerignore
# .dockerignore:
# node_modules
# .git
# *.md
# .env
# docker-compose*.yml

Mistake 2: Not Leveraging Layer Caching

# Bad: any source change invalidates npm install cache
COPY . .
RUN npm ci

# Good: dependency install is cached unless package.json changes
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

Mistake 3: Using :latest in Production

# Bad: unpredictable, breaks reproducibility
FROM node:latest

# Good: pinned version, reproducible builds
FROM node:20.11.1-bookworm-slim

Mistake 4: Running as Root

# Bad: container runs as root by default
CMD ["node", "server.js"]

# Good: dedicated non-root user
RUN useradd -r appuser
USER appuser
CMD ["node", "server.js"]

Measuring the Impact

Use docker images and docker history to analyze your images:

# Compare sizes
docker images | grep myapp
myapp    single-stage   abc123   1.2GB
myapp    multi-stage    def456   150MB

# See layer breakdown
docker history myapp:multi-stage

# Scan for vulnerabilities
trivy image myapp:single-stage  # 247 CVEs
trivy image myapp:multi-stage   # 18 CVEs

# Analyze with dive (interactive layer explorer)
dive myapp:multi-stage

<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 190" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="190" rx="12" fill="#0d1117"/><rect x="0" y="0" width="600" height="28" rx="12" fill="#1c2333"/><rect x="0" y="12" width="600" height="16" fill="#1c2333"/><circle cx="18" cy="14" r="5" fill="#ef4444"/><circle cx="34" cy="14" r="5" fill="#f59e0b"/><circle cx="50" cy="14" r="5" fill="#2dd4bf"/><text x="300" y="18" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="monospace">Terminal</text><text x="20" y="50" fill="#2dd4bf" font-size="11" font-family="monospace">$</text><text x="35" y="50" fill="#e2e8f0" font-size="11" font-family="monospace">docker compose up -d</text><text x="20" y="70" fill="#94a3b8" font-size="11" font-family="monospace">[+] Running 5/5</text><text x="20" y="88" fill="#2dd4bf" font-size="10" font-family="monospace"> &#x2713;</text><text x="38" y="88" fill="#94a3b8" font-size="10" font-family="monospace">Network app_default Created</text><text x="20" y="106" fill="#2dd4bf" font-size="10" font-family="monospace"> &#x2713;</text><text x="38" y="106" fill="#94a3b8" font-size="10" font-family="monospace">Container web Started</text><text x="20" y="124" fill="#2dd4bf" font-size="10" font-family="monospace"> &#x2713;</text><text x="38" y="124" fill="#94a3b8" font-size="10" font-family="monospace">Container api Started</text><text x="20" y="142" fill="#2dd4bf" font-size="10" font-family="monospace"> &#x2713;</text><text x="38" y="142" fill="#94a3b8" font-size="10" font-family="monospace">Container db Started</text><text x="20" y="165" fill="#2dd4bf" font-size="11" font-family="monospace">$</text><rect x="35" y="155" width="8" height="14" fill="#e2e8f0" opacity="0.7"/></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">Docker Compose brings up your entire stack with a single command.</p></div>

The Bottom Line

Multi-stage Docker builds are the single highest-impact optimization for container-based deployments. They reduce image sizes by 80%+, cut vulnerability counts by 90%+, speed up deployments (smaller images = faster pulls), and eliminate entire categories of security risks.

The pattern is simple: build in a heavy image, run in a light one. Every Dockerfile you write should use multi-stage builds. There's no reason not to — the build time impact is negligible, and the benefits are substantial.

If your production containers are over 500 MB, multi-stage builds are the first thing to fix.

#docker#containers#devops#ci-cd#optimization

Need help with devops?

TechSaaS provides expert consulting and managed services for cloud infrastructure, DevOps, and AI/ML operations.