alepha@docs:~/docs/framework/guides/deployment$
cat 5-docker.md | pretty
7 min read
Last commit:

#Docker Deployment

The docker build target packages your app for containerized deployment - a generated Dockerfile next to the bundled server, and optionally the image itself, built in the same command.

#Build

bash
alepha build --target=docker

Produces:

txt
dist/
  index.js       # Bundled server (single file)
  public/        # Client assets (if React frontend exists)
  migrations/    # Copied from your project (if present)
  Dockerfile     # Generated - do not edit

The generated Dockerfile is minimal because the app is already bundled:

dockerfile
FROM node:24-alpine
WORKDIR /app

LABEL "dev.alepha.runtime"="node"

COPY --chown=1000:1000 . .
ENV SERVER_HOST=0.0.0.0
USER 1000
CMD ["node", "index.js"]

dev.alepha.runtime is always there, in every generated Dockerfile. An image's runtime appears nowhere in its OCI index, so a registry cannot answer "what runs inside this" and a pusher's claim about it is not evidence - the image states it itself, and a reader gets it out of the config blob with one small GET. It is what lets lore artifacts push-image need no --runtime flag. It is not part of the oci opt-in below.

With --runtime=bun, the base image becomes oven/bun:alpine, the command bun, and the label "bun". An npm install / bun install layer is added only when dist/package.json declares runtime dependencies - Alepha apps normally bundle everything via Vite, so there's usually nothing to install.

#Baking Defaults Into the Image

docker.env and docker.volumes let the app decide what its image looks like out of the box, so docker run needs no flags:

typescript
 1import { defineConfig } from "alepha/cli/config"; 2  3export default defineConfig({ 4  build: { 5    target: "docker", 6    docker: { 7      env: { 8        DATA_DIR: "/data", 9        DATABASE_URL: "sqlite:///data/app.db",10      },11      volumes: ["/data"],12    },13  },14});
dockerfile
FROM node:24-alpine
WORKDIR /app

LABEL "dev.alepha.runtime"="node"

COPY --chown=1000:1000 . .

RUN mkdir -p "/data" && chown 1000:1000 "/data"

ENV SERVER_HOST=0.0.0.0
ENV DATA_DIR="/data"
ENV DATABASE_URL="sqlite:///data/app.db"

VOLUME ["/data"]

USER 1000

CMD ["node", "index.js"]

env entries are emitted after SERVER_HOST, so an app that sets SERVER_HOST itself wins. Values are escaped, and anything passed with docker run -e still overrides them. These are defaults, not secrets: everything here is readable with docker inspect.

#The Container User

The standard variant runs as uid 1000, which exists in both official bases (node and bun). A numeric id is emitted rather than a name because docker.from is a supported override and USER node fails the build outright on a base without that user.

COPY --chown matches it, because the default DATA_DIR is node_modules/.alepha - inside /app - so an app that has not moved it needs a writable /app. Each declared volume is created and chowned before its VOLUME line: a named volume inherits ownership from the image directory at that path.

Bind mounts do not follow this. -v ./data:/data keeps the host directory's ownership, so the host has to make it writable by uid 1000 itself. Named volumes (-v app-data:/data) need nothing.

Override with docker.user, including back to root:

typescript
1export default defineConfig({2  build: {3    target: "docker",4    docker: { user: "root" },5  },6});

Compile mode has no default and stays root, because the distroless base has no shell and a declared volume cannot be prepared at build time. Set docker.user explicitly there if the image needs a non-root user.

#Build the Image Too

Add --image to run docker build as the last step:

bash
alepha build --target=docker --image           # <tag>:latest
alepha build --target=docker --image=1.3.4     # <tag>:1.3.4
alepha build --target=docker --image=myorg/app:v2   # full override

The default tag comes from config:

typescript
 1import { defineConfig } from "alepha/cli/config"; 2  3export default defineConfig({ 4  build: { 5    target: "docker", 6    docker: { 7      image: { 8        tag: "ghcr.io/myorg/myapp", 9        args: "--platform linux/amd64",10        oci: true, // add org.opencontainers.image.* labels (git revision, timestamp, version)11        source: "https://github.com/myorg/myapp",12        title: "My App",13        description: "Self-hosted My App",14        licenses: "Apache-2.0",15      },16    },17  },18});

oci: true derives three labels - revision (git commit SHA), created and version - and passes them to docker build, because each describes that particular build. The four config fields beside it go into the generated Dockerfile as LABEL lines instead, so they survive a build Alepha did not run:

dockerfile
LABEL "org.opencontainers.image.source"="https://github.com/myorg/myapp"
LABEL "org.opencontainers.image.title"="My App"

That matters as soon as something other than --image builds the image - a release pipeline running docker buildx build on dist/ for two architectures, say. A field left unset produces no label rather than an empty one.

dev.alepha.runtime is not part of this opt-in. It is emitted whether or not oci is set, because it is Alepha's own contract with its registry rather than an OCI annotation: an app that never configured oci would otherwise ship an image whose lore artifacts push-image is refused for a missing label, for a reason nothing in its config explains.

source is the one that matters for a published package: it is what links a GHCR package to its repository, and without it the package page stands alone, with no README and no repo link. It is never derived from the git remote - an SSH remote is not a URL, a CI checkout may have no remote at all, and a fork would publish either the upstream's URL or its own with nothing inside the build able to tell which is meant. A wrong source on a published image is worse than a missing one.

#Configuration

Option Default Description
docker.from node:24-alpine / oven/bun:alpine Base image for the FROM instruction
docker.command node / bun Command that runs the server
docker.install [] Extra packages installed into the image (e.g. ["wrangler"] for an app that shells out to a CLI)
docker.env {} ENV defaults baked into the image, emitted after SERVER_HOST
docker.volumes [] VOLUME mount points, created and chowned to the container user first
docker.user 1000 (root in compile mode) USER the server runs as
docker.image - Image tag, extra docker build args, OCI labels including source (used with --image)
compile - Single-binary compile mode, a build-level option, see below

#Compile Mode (Single Static Binary)

With --runtime=bun --compile (or build.compile in config), the app is compiled to one static binary via bun build --compile, client assets included, and packaged in a distroless base image. The compilation itself is the same as for the bare target, for linux-musl:

bash
alepha build --target=docker --runtime=bun --compile --image
dockerfile
FROM gcr.io/distroless/static-debian12
WORKDIR /app

LABEL "dev.alepha.runtime"="bun"

COPY app .
ENV SERVER_HOST=0.0.0.0
ENTRYPOINT ["/app/app"]
  • The binary lands at dist/app (dist/<name> with --compile <name>, and the COPY and ENTRYPOINT follow). dist/index.js, dist/server/, dist/package.json and dist/public/ are removed: the client assets are inside the binary, which serves them itself.
  • With --image, the image is built after the binary exists, at the end of the build.
  • The image runs as root unless docker.user says otherwise - distroless has no shell, so a declared volume cannot be created and chowned at build time. The generated file carries a comment saying so.
  • No package manager runs inside the image (distroless has no npm), so docker.install is ignored and any non-empty runtime dependencies fail the build loudly - compile requires fully-bundled output.
  • compile accepts a binary name (--compile <name> on the command line) or an object for the name, the Bun target triple (bun-linux-arm64-musl, ...) and minification. The base image is docker.from, distroless by default in this mode.

The result is a minimal image with no shell, no package manager, and no interpreter - a small attack surface and a fast cold start.

#Running

bash
docker run -p 3000:3000 --env-file .env.production ghcr.io/myorg/myapp:latest

SERVER_HOST=0.0.0.0 is baked into the image so the server binds correctly inside the container; set SERVER_PORT if you need a port other than 3000 - or let a host that injects PORT (Cloud Run, Fly) decide, which the server reads as a fallback when SERVER_PORT is unset. Migrations ship in the image under /app/migrations - run them on startup via your orchestration, or from a release step with alepha db migrations apply pointed at the same DATABASE_URL.

#Tips

Use OCI labels in CI. image.oci: true stamps the git revision and build time on the image - invaluable when you're staring at a registry full of latest tags. Add source before you publish anywhere public, or the package page will not link back to the repository.

Prefer compile mode for public-facing services. Distroless plus a static binary removes whole vulnerability classes from the image.

Keep secrets out of the image. Nothing in dist/ should contain secrets - inject them at runtime via --env-file or your orchestrator's secret store.