#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
alepha build --target=docker
Produces:
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:
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:
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});
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:/datakeeps 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:
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:
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:
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:
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:
alepha build --target=docker --runtime=bun --compile --image
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 theCOPYandENTRYPOINTfollow).dist/index.js,dist/server/,dist/package.jsonanddist/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.usersays 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), sodocker.installis ignored and any non-empty runtimedependenciesfail the build loudly - compile requires fully-bundled output. compileaccepts 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 isdocker.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
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.