How to create a minimum Deno 2.0 Docker image

With the release of Deno 2.0, I took the opportunity to experiment with the deno compile feature and its potential in combination with Docker.

This feature enables you to compile your script into a self-contained binary, allowing users to run your program without needing to have Deno installed.

I build a basic "Hello, World!" server using the oak server that listens on port 8000:

import { Application, Router } from "https://deno.land/x/oak/mod.ts";

const router = new Router();

router.get("/", (ctx) => {
  ctx.response.body = "Hello world";
});

const app = new Application();
app.use(router.routes());
app.use(router.allowedMethods());

app.listen({
  port: 8000,
});

app.addEventListener("listen", ({ hostname, port, secure }) => {
  console.log(
    `Listening on: ${secure ? "https://" : "http://"}${
      hostname ?? "localhost"
    }:${port}`,
  );
});

Using the Deno 2 image

Here’s how the Dockerfile looks using the official Deno 2 image:

# Use the official Deno image from the Docker Hub
FROM denoland/deno:alpine-2.0.4

# Set the working directory
WORKDIR /app

# Copy the application code
COPY main.ts deno.json deno.lock ./

# Install dependencies
RUN deno install

EXPOSE 8000

# Set the command to run the main.ts file
CMD ["deno", "--allow-net", "main.ts"]

Next, let’s build the image and inspect the image list:

❯ docker build -t deno-classic:latest .

❯ docker images
REPOSITORY        TAG       IMAGE ID       CREATED       SIZE
deno-classic      latest    5c74fb50eecd   2 seconds ago   231MB

Compiling with Deno

The next step is to build a binary and use a smaller base image for the final build. Drawing from my experience with Go, I created the following Dockerfile:

# Stage 1: Build
FROM --platform=${BUILDPLATFORM} denoland/deno:2.0.4 AS builder

WORKDIR /deno-app

# Copy the application code
COPY main.ts deno.json deno.lock ./

# Compile the binary
RUN deno compile --output /app/main --allow-net main.ts

# Stage 2: Run
FROM scratch

COPY --from=builder /app/main /

EXPOSE 8000

ENTRYPOINT ["/main"]

However, attempting to run this image resulted in the following error:

❯ docker run -it --rm -p 8000:8000 deno-standalone
exec /main: no such file or directory

Upon closer inspection, it became clear that the binary wasn’t statically compiled:

 ldd ./main
        linux-vdso.so.1 (0x0000ffffaed7f000)
        libdl.so.2 => /lib/aarch64-linux-gnu/libdl.so.2 (0x0000ffffaed20000)
        libgcc_s.so.1 => /lib/aarch64-linux-gnu/libgcc_s.so.1 (0x0000ffffaece0000)
        libpthread.so.0 => /lib/aarch64-linux-gnu/libpthread.so.0 (0x0000ffffaecb0000)
        libm.so.6 => /lib/aarch64-linux-gnu/libm.so.6 (0x0000ffffa9d60000)
        libc.so.6 => /lib/aarch64-linux-gnu/libc.so.6 (0x0000ffffa9bb0000)
        /lib/ld-linux-aarch64.so.1 (0x0000ffffaed42000)

This indicates that the binary cannot be used with the scratch Docker image. As of this writing, there is currently no method to produce a statically linked binary with Deno.

With help from the community, I managed to run it successfully using a smaller image variant based on Distroless from Google: gcr.io/distroless/cc.

❯ docker build -t deno-standalone:latest .

❯ docker images
REPOSITORY        TAG       IMAGE ID       CREATED             SIZE
deno-standalone   latest    df29ed80c548   4 seconds ago       169MB
deno-classic      latest    5c74fb50eecd   About an hour ago   231MB

❯ docker run -it --rm -p 8000:8000 deno-standalone
Listening on: http://0.0.0.0:8000

This approach results in a 62MB reduction—an impressive optimization!

Of course, I would recommend using such a minimal image for production, as it lacks many features and utilities, including a shell.

Hope that helps !