Skip to main content

Gleam on Miren

Gleam isn't auto-detected, so you deploy it with a Dockerfile.miren that compiles your project to an Erlang shipment and runs it on the BEAM. This guide targets Erlang (the default for server apps using mist/wisp).

Let your agent do this

Ask your AI coding agent to "set up this Gleam app on Miren" after installing the Miren agent skills. It can add the Dockerfile.miren, confirm your server binds 0.0.0.0:$PORT, wire up environment variables, and deploy — using this page as its reference.

Do you need a Dockerfile?

Yes. Miren doesn't auto-detect the BEAM yet, so add a Dockerfile.miren to your project root. Miren builds from it instead of guessing the stack — see Using Dockerfile.miren.

Validated pattern — adapt the versions

The Dockerfile and app below were deployed to a live Miren cluster with Gleam 1.15 and mist v6. Adjust the Gleam/Erlang image tags and your app name to match your project — check github.com/gleam-lang/gleam/pkgs/container/gleam for available tags, and keep the runtime Erlang version in sync with the builder (see the warning below the Dockerfile).

Want native support?

Miren auto-detects and builds common stacks (Python, Node, Bun, Go, Ruby, Rust) without a Dockerfile. This language isn't one of them yet — if you'd like first-class support, request it.

Bind to the injected port

Miren injects PORT and routes traffic to it, so your HTTP server must read PORT and listen on 0.0.0.0. With mist (used directly or under wisp), read the port from the environment — the envoy package is the usual way. This example is validated against mist v6:

import envoy
import gleam/bytes_tree
import gleam/erlang/process
import gleam/http/request.{type Request}
import gleam/http/response.{type Response}
import gleam/int
import gleam/result
import mist.{type Connection, type ResponseData}

pub fn main() {
let port =
envoy.get("PORT")
|> result.try(int.parse)
|> result.unwrap(8080)

let assert Ok(_) =
handle_request
|> mist.new
|> mist.bind("0.0.0.0")
|> mist.port(port)
|> mist.start

process.sleep_forever()
}

fn handle_request(_req: Request(Connection)) -> Response(ResponseData) {
response.new(200)
|> response.set_header("content-type", "text/plain")
|> response.set_body(mist.Bytes(bytes_tree.from_string("Hello from Gleam!\n")))
}
Bind to the injected port

Bind 0.0.0.0 and use the PORT value — an app that hardcodes a port or localhost won't receive traffic.

The mist builder API changes between major versions; on older releases the final call is mist.start_http rather than mist.start. Add the dependencies with gleam add mist envoy.

The Dockerfile

Create Dockerfile.miren in your project root. The erlang shipment is copied whole, so nothing here depends on your gleam.toml name:

ARG GLEAM_VERSION=v1.15.0

# ----- Build stage -----
FROM ghcr.io/gleam-lang/gleam:${GLEAM_VERSION}-erlang-alpine AS builder

WORKDIR /build
COPY . .
RUN gleam export erlang-shipment

# ----- Runtime stage -----
# The Erlang major version here MUST match the OTP version in the builder image
# above, or the compiled BEAM files fail to load at startup.
FROM erlang:28-alpine

RUN apk add --no-cache ca-certificates libstdc++ openssl ncurses-libs

WORKDIR /app
COPY --from=builder /build/build/erlang-shipment ./

EXPOSE 8080

# entrypoint.sh is generated by `gleam export erlang-shipment`
CMD ["./entrypoint.sh", "run"]

gleam export erlang-shipment produces build/erlang-shipment/ containing your compiled app, its dependencies, and an entrypoint.sh. Running ./entrypoint.sh run boots the release.

Match the runtime Erlang version to the builder

BEAM bytecode won't load on an older Erlang than it was compiled with. The pinned gleam:v1.15.0-erlang-alpine builder ships OTP 28, so the runtime stage uses erlang:28-alpine. Whenever you bump the Gleam image tag, check the OTP version it ships (docker run --rm ghcr.io/gleam-lang/gleam:<tag>-erlang-alpine erl -eval 'io:format("~s~n",[erlang:system_info(otp_release)]),halt().' -noshell) and set the runtime image to the same major version.

.dockerignore

Keep build artifacts out of the image context:

.git
build

Set up the app

Even with a Dockerfile.miren, Miren needs at least one service defined — it doesn't use the image's CMD as the start command. Add a Procfile next to your Dockerfile.miren:

web: /app/entrypoint.sh run

The command is the release entrypoint the shipment generated. Use the absolute path (/app/entrypoint.sh, matching the Dockerfile's WORKDIR /app); the script resolves its own release directory, so it works regardless of the working directory.

Then create .miren/app.toml naming your app and deploy from your project root:

name = "gleam-bench"
miren deploy
Deploying without a Procfile fails

If no service is defined, the build succeeds but the deploy stops with no services defined: please define at least one service in a Procfile or .miren/app.toml. Defining [services.web] with the same command in app.toml works too.

Environment variables

Set variables with miren env set-e for plain values, -s for secrets (masked in output and logs). Read them in Gleam with envoy.get("KEY"):

miren env set -s DATABASE_URL
miren env set -s SECRET_KEY

miren env set -s SECRET_KEY (no value) prompts with masked input. You can also declare variables in .miren/app.toml:

[[env]]
key = "DATABASE_URL"
value = ""
required = true
sensitive = true
description = "Postgres connection string"

Need a managed Postgres database? Add a miren-postgresql addon and Miren injects DATABASE_URL for you. See App Configuration — Environment Variables.

Agent quick reference

  • Detection: none — requires Dockerfile.miren (Erlang shipment)
  • Build: gleam export erlang-shipmentbuild/erlang-shipment/ with entrypoint.sh
  • Runtime image: Erlang major version must match the builder's OTP (the pinned v1.15.0 image ships OTP 28 → erlang:28-alpine)
  • Service is required: define a Procfile (web: /app/entrypoint.sh run) or [services.web] — the image CMD is not used
  • Port: read PORT via envoy.get("PORT"); bind mist/wisp to 0.0.0.0
  • mist API: v6 ends the builder with mist.start; older versions use mist.start_http
  • Env vars: miren env set -e/-s, or [[env]] in app.toml; read with envoy.get/1
  • Database: optional [addons.miren-postgresql] injects DATABASE_URL

Next steps