Run Cruma in a container
Ship your app and the Cruma agent in one container: the agent starts your app, and a registered tunnel gives it a public address. The tunnel credentials, the app's passwords and every other secret come from the container's environment or from mounted secret files. None of them is in the image, in git or in the config file.
🚧 In development
The agent release that adds
cruma up, theCRUMA_TUNNEL_IDandCRUMA_TUNNEL_SECRETvariables and${env:…}/${file:…}references is not published yet. The installer still installs an agent without them, so the image below does not start as described until that release ships.
What you need
- A tunnel ID and secret from your account. A container never falls back to an anonymous tunnel.
- A glibc-based image such as Debian, Ubuntu or Fedora on x86_64 or arm64. The Linux installer does not support musl images such as Alpine.
- Your app, and a
cruma.yamlnext to it.
The project
cruma.yaml hosts the app as a process, serves it behind basic auth and holds no secret, only references. It sets no tunnel_id or tunnel_secret, so the agent reads CRUMA_TUNNEL_ID and CRUMA_TUNNEL_SECRET (see Credentials from the environment).
backends: []
processes:
- id: web
command: python3
args: ["app.py"]
working_directory: "$cfg_dir"
env:
DB_PASSWORD: "${env:APP_DB_PASSWORD}"
frontends:
- hostname: app # app.<tunnel-id>.tun.cruma.io
process_id: web
middlewares:
- type: basic_auth
users:
- ["admin", "${env:APP_BASIC_AUTH_PASSWORD}"]
listeners:
- kind: cruma
The agent gives the process a free port in PORT; your app listens on it. See Configuration for processes, frontends and middlewares.
The Dockerfile
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl python3 \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home app
USER app
RUN curl -fsSL https://files.cruma.io/files/tunnel-agent/install/linux-install.sh | bash
ENV PATH="/home/app/.local/bin:${PATH}"
WORKDIR /home/app/site
COPY --chown=app:app app.py cruma.yaml ./
CMD ["cruma", "--headless", "up"]
- Replace
python3andapp.pywith your own runtime and app. - The installer needs no
sudoand putscrumain~/.local/bin. --headlessis required: a container has no terminal, and without the flag the agent tries to open its terminal interface and stops.cruma upfindscruma.yamlin the working directory and fails closed (see When something is missing).- Put no
ENV,ARGorCOPYof a secret in the Dockerfile: image layers anddocker historyshow them to everyone who can pull the image.
docker build -t shop .
Pass the secrets at run time
With -e NAME and no value, Docker takes the value from your shell:
docker run -d --name shop \
-e CRUMA_TUNNEL_ID \
-e CRUMA_TUNNEL_SECRET \
-e APP_DB_PASSWORD \
-e APP_BASIC_AUTH_PASSWORD \
shop
Or keep them in an env file outside git, one NAME=value per line without quotes:
docker run -d --name shop --env-file cruma.env shop
The app is then at https://app.<tunnel-id>.tun.cruma.io, behind the password.
Docker secrets and Compose
Secrets mounted as files appear under /run/secrets/<name>. Point the secret fields at the files with ${file:PATH}. The file's trailing whitespace is removed, so a newline at the end is fine. The tunnel ID is not secret and stays in the environment. Use this cruma.secrets.yaml instead of cruma.yaml:
backends: []
tunnel_secret: "${file:/run/secrets/cruma_tunnel_secret}"
processes:
- id: web
command: python3
args: ["app.py"]
working_directory: "$cfg_dir"
env:
DB_PASSWORD: "${file:/run/secrets/app_db_password}"
frontends:
- hostname: app
process_id: web
middlewares:
- type: basic_auth
users:
- ["admin", "${file:/run/secrets/app_basic_auth_password}"]
listeners:
- kind: cruma
The Compose file mounts it over the image's cruma.yaml. Keep secrets/ out of git and out of the build context (.gitignore and .dockerignore).
services:
app:
build: .
environment:
- CRUMA_TUNNEL_ID
volumes:
- ./cruma.secrets.yaml:/home/app/site/cruma.yaml:ro
secrets:
- cruma_tunnel_secret
- app_db_password
- app_basic_auth_password
secrets:
cruma_tunnel_secret:
file: ./secrets/cruma_tunnel_secret
app_db_password:
file: ./secrets/app_db_password
app_basic_auth_password:
file: ./secrets/app_basic_auth_password
CRUMA_TUNNEL_ID=TUNNEL_ID docker compose up -d
Kubernetes
Store the credentials in a Secret named cruma-credentials, with the same keys the Kubernetes ingress controller uses, in your app's namespace. The app's own secrets go in a second one:
kubectl create secret generic cruma-credentials \
--from-literal=CRUMA_TUNNEL_ID=TUNNEL_ID \
--from-literal=CRUMA_TUNNEL_SECRET=SECRET_KEY
kubectl create secret generic app-secrets \
--from-literal=APP_DB_PASSWORD=DB_PASSWORD \
--from-literal=APP_BASIC_AUTH_PASSWORD=LOGIN_PASSWORD
envFrom turns each key into an environment variable, which is all the image's cruma.yaml needs:
apiVersion: apps/v1
kind: Deployment
metadata:
name: shop
spec:
replicas: 1
selector:
matchLabels:
app: shop
template:
metadata:
labels:
app: shop
spec:
containers:
- name: shop
image: registry.example.com/shop:1.0
envFrom:
- secretRef:
name: cruma-credentials
- secretRef:
name: app-secrets
Keep replicas: 1. A tunnel accepts one agent unless it is on Pro, and a second pod is refused until the first one disconnects.
To mount the secrets as files instead, create one Secret with a key per file and put cruma.secrets.yaml in a ConfigMap:
kubectl create secret generic app-secret-files --from-file=secrets/
kubectl create configmap cruma-config --from-file=cruma.yaml=cruma.secrets.yaml
Then replace envFrom in the container and add the volumes:
env:
- name: CRUMA_TUNNEL_ID
valueFrom:
secretKeyRef:
name: cruma-credentials
key: CRUMA_TUNNEL_ID
volumeMounts:
- name: secrets
mountPath: /run/secrets
readOnly: true
- name: config
mountPath: /home/app/site/cruma.yaml
subPath: cruma.yaml
readOnly: true
volumes:
- name: secrets
secret:
secretName: app-secret-files
- name: config
configMap:
name: cruma-config
Where secrets live
| Place | Secrets allowed |
|---|---|
Container environment (-e, --env-file, Compose environment, Kubernetes env and envFrom) | Yes, read with ${env:NAME}; the tunnel credentials need no reference |
| Mounted secret file (Compose secrets, a Kubernetes Secret volume) | Yes, read with ${file:PATH} |
cruma.yaml | References only. cruma up refuses a literal secret |
Image layers (ENV, ARG, COPY) and git | No |
- The agent logs, shows and saves the reference, never the value.
cruma config show, the desktop app and MCP tools show${env:APP_DB_PASSWORD}, and the tunnel secret shows as${env:CRUMA_TUNNEL_SECRET}or nothing. - A hosted process receives the container's environment except
CRUMA_TUNNEL_ID,CRUMA_TUNNEL_SECRETand any variable the agent itself reads through${env:NAME}in its own fields, such as the basic-auth password above. It receives its ownenventries under the names you gave them. A process that needs a withheld variable names it in itsenv. - Anyone who can run
docker inspecton the container, or read the Pod, can read its environment. Where that matters, mount files. - A tunnel's ID and secret are scoped to that one tunnel. A leaked container environment exposes that tunnel and nothing else in your account.
When something is missing
The agent stops with exit code 1 and serves nothing, so an orchestrator shows a failing container instead of a public tunnel that no one meant to open. The error names what is missing and never prints a value.
| Situation | Error |
|---|---|
| No tunnel credentials | this run needs a registered tunnel, but no credentials are set. Set CRUMA_TUNNEL_ID and CRUMA_TUNNEL_SECRET, or set tunnel_id and tunnel_secret in the config file, or run cruma config set-credentials`` |
| Only one of the two | tunnel credentials are incomplete: tunnel_secret is not set. Set both tunnel_id and tunnel_secret in the config, or both CRUMA_TUNNEL_ID and CRUMA_TUNNEL_SECRET |
| A referenced variable is not set | secret reference in config field `processes[web].env.DB_PASSWORD`: `${env:APP_DB_PASSWORD}`: environment variable is not set |
| A referenced file is missing | secret reference in config field `tunnel_secret`: `${file:/run/secrets/cruma_tunnel_secret}`: cannot read file: entity not found |
A literal secret in cruma.yaml | cruma up refused the project file because it contains literal secrets:, then each field path such as frontends[0].middlewares[0].users[0].password (password) |
If Cruma rejects the tunnel ID or secret, the agent logs "Cruma rejected this tunnel ID or secret" and stops connecting; see Setting credentials.