Introduction
minienv creates effortless remote mini environments generated directly from your docker compose config.
The problem
You already describe your stack in docker-compose.yml. Getting that same stack
onto a shared cluster — so a teammate can click a link and review your branch —
normally means maintaining a second description of it: Kubernetes manifests, or
a Helm chart, that drifts from compose the moment anyone adds a service.
minienv removes the second description. It reads the compose file you already have, plus a small extension block, and deploys it.
What it does
Given a compose file, minienv deploy:
- Builds and pushes images for any service with a
build:section. - Prepares the target — creating the Kubernetes namespace, or the remote directory the compose project will live in.
- Deploys your services, respecting
depends_onorder. - Optionally exposes a service publicly via ngrok, so it can be reviewed from anywhere.
minienv destroy tears the whole thing down again.
A complete, working config can be this small:
x-minienv:
k8s:
context: minikube
namespace: my-feature-branch
services:
hello:
image: rgonnella/demo-hello:latest
ports:
- "8080:8080"
Swap the k8s block for a docker one and the same compose file deploys to a
remote host instead:
x-minienv:
docker:
namespace: my-feature-branch
transport:
ssh:
host: dev-box.example.com
identity: ~/.ssh/id_ed25519
Where to go next
- Getting Started — install it and deploy something.
- Configuration — the extension fields, and which compose fields minienv reads.
- Configuration Reference — every field, in tables.
Two targets, one at a time.
k8sdeploys each service as a Helm release to a cluster;dockerdeploys the whole project to a single remote host. See Deployers.
License
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
Getting Started
Prerequisites
| Requirement | When you need it |
|---|---|
docker with buildx | Only for services that have a build: section |
git | Only if you use a +git image tag |
| A reachable Kubernetes context | Only for the k8s deployer |
An SSH-reachable host with docker and its compose plugin | Only for the docker deployer |
You do not need the helm binary installed.
For the docker deployer, the remote host must already be in your
~/.ssh/known_hosts. minienv offers no prompt or bypass, so connect once by
hand first. Encrypted identity files are not supported.
Install
go install github.com/robgonnella/minienv/cmd/minienv@latest
Check it:
minienv version
Your first mini environment
Add a top-level x-minienv block to your compose file. This is the only thing
minienv requires:
x-minienv:
k8s:
context: minikube
namespace: my-feature-branch
services:
hello:
image: rgonnella/demo-hello:latest
ports:
- "8080:8080"
Deploy it:
minienv deploy
Tear it down:
minienv destroy
Both commands accept up and down as aliases.
Preview without deploying
--dry-run previews a deploy without changing the target:
minienv deploy --dry-run
It is not entirely offline. Images are still built locally with buildx — they
are simply not pushed.
Kubernetes. The cluster is still contacted, read-only: reachability checks, API discovery, and reading existing release state. Nothing is applied.
A
k8sdry run needs a reachable cluster. It changes nothing, but it is not a way to check a config without one.
Docker. Nothing remote happens at all. minienv logs the paths it would write and opens no SSH session.
File discovery
minienv finds your compose files exactly the way docker compose does —
including default file names, COMPOSE_FILE, .env loading, and variable
interpolation. The same flags work too:
minienv deploy -f compose.yml -f compose.override.yml
So every developer can have their own namespace from an environment variable:
x-minienv:
k8s:
context: minikube
namespace: $MINIENV_NAMESPACE
Editor autocomplete
minienv ships a JSON Schema that wraps the official compose spec and adds its own extension fields. Point at it from the top of your compose file:
# $schema: https://raw.githubusercontent.com/robgonnella/minienv/refs/heads/main/schema/schema.json
Worth doing: the schema is stricter than the runtime. It rejects unknown keys, whereas at deploy time a misspelled extension key is silently ignored.
Next steps
- Configuration — what minienv reads from your compose file, and what it ignores.
- Exposing Services — make the environment reachable for review.
Configuration
minienv adds one top-level field to your compose file, plus a per-service field for whichever target you picked.
| Field | Where | Purpose |
|---|---|---|
x-minienv | top level | Chooses and configures the deployment target |
x-minienv-k8s-service | under a service | Per-service overrides, Kubernetes target |
x-minienv-docker-service | under a service | Per-service overrides, Docker target |
All three are standard compose extension fields, so docker compose ignores
them and the same file keeps working locally.
Use the service extension matching your target. The other one is silently ignored.
Configure exactly one target in x-minienv — see
Deployers for what each one does and what it needs.
Per-service overrides
Optional — most services need nothing here. Every field is listed in the Configuration Reference.
Kubernetes
services:
api:
image: myorg/api:v1
ports:
- "8080:8080"
x-minienv-k8s-service:
replicas: 2
resources:
limits:
memory: 512Mi
Anything set here overrides the equivalent compose value.
Docker
services:
api:
image: myorg/api:v1
ports:
- "8080:8080"
x-minienv-docker-service:
image:
tag: +git
ngrok:
port: 8080
Supplying what compose does not carry
Your compose file stays as it is. minienv reads image, build, ports,
command, environment, healthcheck and depends_on; anything else a target
needs comes from the extension.
Kubernetes
These compose keys are not read. Set the extension field when the deployed service needs what they describe:
| Compose key | Set in x-minienv-k8s-service |
|---|---|
entrypoint | command |
volumes | volumes and volumeMounts |
configs | configMapFrom, mounted through volumes |
secrets | manifests, mounted through volumes |
deploy.replicas | replicas |
labels | podLabels |
user | securityContext |
Storage is declared the Kubernetes way:
x-minienv-k8s-service:
volumes:
- name: cache
emptyDir: {}
volumeMounts:
- name: cache
mountPath: /var/cache
A volume backed by a ConfigMap can come from configMapFrom. One backed by a
Secret or PersistentVolumeClaim needs that resource to exist — declare it in a
file and list it under manifests.
networks, restart, profiles, working_dir, extra_hosts and expose
have no Kubernetes equivalent and are ignored.
Docker
The project reaches the remote host as written, apart from what cannot follow it
there: bind mounts, whose host paths do not exist on that machine, and
secrets or configs declared with file:. Named volumes are kept.
To carry a host path across anyway, name it under copy and minienv sends it to
the remote host and bind mounts it there:
x-minienv-docker-service:
copy:
- hostPath: conf/api.yml
containerPath: /etc/api/api.yml
environment reaches the remote host already interpolated, so ${DB_HOST}
arrives as the value it had on your machine. To keep a value off the remote
entirely, use the bare - SOME_VAR form: with nothing set locally it stays
unresolved, and the remote host supplies it from its own environment.
Next steps
- Configuration Reference — every field, with the per-field detail.
- Images and Builds
- Exposing Services
- Jobs and Dependencies
Images and Builds
Every service resolves to one image: a repository and a tag. The image fields
below are the same under x-minienv-k8s-service and x-minienv-docker-service;
the examples use the first.
Building from source
Any service with a build: section is built and pushed before the deploy
starts:
services:
api:
build: .
x-minienv-k8s-service:
image:
repository: myorg/api
tag: v1
Where the image name comes from
minienv needs a repository and a tag. The extension fields win, and compose
image: supplies whichever of them you leave unset, so this is enough for a
service you are not building:
services:
db:
image: postgres:15 # repository: postgres, tag: 15
An untagged image: myapp resolves to myapp:latest — see
Fixed tags.
A digest reference is rejected.
image: myapp@sha256:...fails the deploy — setimage.repositoryandimage.tagin the extension instead.
Tagging per commit with +git
The literal string +git in a tag is replaced with the current short commit
SHA:
x-minienv-k8s-service:
image:
repository: myorg/api
tag: +git
Every commit gets a unique tag, so the target always pulls the image you just
built instead of a cached one. It works as a suffix too — tag: v1-+git
produces something like v1-a1b2c3d.
Platforms
Override when the target’s architecture differs from your laptop, or when you need both:
x-minienv-k8s-service:
image:
repository: myorg/api
tag: +git
platforms:
- linux/amd64
- linux/arm64/v8
Fixed tags
A tag rebuilt in place — latest, dev — names an image the target may
already hold, so a redeploy can keep running the old one. +git sidesteps this
by changing the tag every commit. Where it is not an option:
Kubernetes
Set the pull policy to Always:
x-minienv-k8s-service:
image:
pullPolicy: Always
Or set recreate: true, which replaces the pods on every deploy even when
nothing in the release changed.
Docker
The remote host pulls with docker compose up -d, which by default keeps an
image it already has. Set compose pull_policy on the service; it reaches the
host as written:
services:
api:
image: myorg/api:latest
pull_policy: always
Private registries
Kubernetes
x-minienv-k8s-service:
imagePullSecrets:
- name: my-registry-creds
The secret must already exist in the target namespace; minienv does not create it.
Docker
The remote host’s Docker daemon pulls the image, so it must already be logged in to the registry. minienv does not log it in.
Exposing Services
A mini environment is only useful if someone can reach it. minienv exposes services publicly through ngrok.
ngrok is the only exposure mechanism. minienv never creates an Ingress, a LoadBalancer Service, or a PersistentVolumeClaim. If you need those, manage them outside minienv or via
manifestsin the k8s service extension.
Setup
1. Set your auth token. minienv reads NGROK_AUTHTOKEN from the
environment:
export NGROK_AUTHTOKEN=your_token_here
If
NGROK_AUTHTOKENis not set, all ngrok configuration is silently discarded. The deploy succeeds, nothing is exposed, and no error is printed. This is by far the most common reason for “why is nothing public”. Check this first.
2. Pick a port to expose:
services:
api:
image: myorg/api:v1
ports:
- "8080:8080"
x-minienv-k8s-service: # or x-minienv-docker-service
ngrok:
port: 8080
On deploy the service becomes reachable at a public URL. The examples below use
x-minienv-k8s-service; use x-minienv-docker-service for the docker target.
What gets published
One ngrok agent serves every published service in a namespace.
Each endpoint is named <namespace>-<service> — the name shown in the URL table
and the ngrok dashboard, and the prefix is what keeps one developer’s
environment from resolving another’s URL on a shared account.
The agent is replaced only when the published set or the auth token changes. That is what decides whether a URL survives a redeploy — an unreserved one is reassigned every time the agent is replaced.
Which port number to use
ngrok.port is the container side of a compose mapping — the port your
process listens on. The host side plays no part in it.
ports:
- "8080:3000" # host 8080, container 3000
x-minienv-k8s-service: # or x-minienv-docker-service
ngrok:
port: 3000
A stable URL
By default ngrok assigns a random URL, and a randomly assigned URL cannot be re-claimed once the agent serving it goes away — not on any plan. A stable URL means a domain reserved on your ngrok account, set explicitly:
x-minienv-k8s-service:
ngrok:
port: 8080
url: https://my-branch.example.ngrok.app
Useful when the URL goes in a pull request description, or when an external service needs a fixed webhook target.
The domain has to already be reserved. The agent refuses one the account does not hold, which fails the deploy. Every account has at least one static domain, including a free one; a paid plan buys more and lets you name them.
On a shared ngrok account, interpolate the namespace into it. This value is committed to
compose.yml, so a hardcoded domain is the same domain for everyone on the team, and two agents cannot claim one domain at once:url: https://${MINIENV_NAMESPACE}-api.example.ngrok.appA wildcard reservation covers the whole pattern.
Traffic policy
trafficPolicy takes an ngrok on_http_request policy — for adding
authentication in front of the environment, rewriting headers, or restricting
access by IP:
x-minienv-k8s-service:
ngrok:
port: 8080
trafficPolicy: |
on_http_request:
- actions:
- type: basic-auth
config:
credentials:
- "reviewer:hunter2"
To apply one policy to every service, set it once at the top level. Services that define their own override it:
x-minienv:
ngrok:
trafficPolicy: |
on_http_request:
- actions:
- type: basic-auth
...
The API key
NGROK_API_KEY is a second, optional credential, used only to print the URL
table after a deploy:
export NGROK_API_KEY=your_api_key_here
====== Published Service URLs ======
Service Name URL
-- ----
api https://quiet-mesa-1234.ngrok.app
web https://my-branch.example.ngrok.app
Jobs and Dependencies
Run-to-completion jobs
On Kubernetes, set deploymentType: job to deploy a service as a Job instead of
a long-running Deployment. This is for work that finishes: migrations, seed
data, fixtures, smoke tests.
services:
migrate:
image: myorg/api:v1
command: ["./manage", "migrate"]
x-minienv-k8s-service:
deploymentType: job
Every deploy creates a fresh Job, so re-running a migration works without manually deleting the previous one.
What a job ignores
A job accepts the full extension schema, but several keys have no effect on one:
replicas, service.create, service.type, the three probes, and ngrok — a
job has no Service for an endpoint to route to.
Those keys are absent from a job’s chart values, so a manifests file that
reads one — .Values.replicas, say — renders empty rather than failing.
Ordering with depends_on
On Kubernetes, minienv deploys services in depends_on order and destroys them
in reverse, with independent services going up in parallel. On Docker your
depends_on block reaches the remote host as written, and compose orders the
project there.
services:
api:
image: myorg/api:v1
ports:
- "8080:8080"
depends_on:
migrate:
condition: service_completed_successfully
migrate:
image: myorg/api:v1
command: ["./manage", "migrate"]
x-minienv-k8s-service:
deploymentType: job
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:15
environment:
POSTGRES_PASSWORD: secret
ports:
- "5432:5432"
healthcheck:
test: pg_isready -U postgres -d postgres
This deploys postgres, waits for it, runs migrate to completion, then
deploys api.
How the conditions are treated
On Kubernetes only the dependency edges are used, not the condition:
values — so service_started and service_healthy behave identically.
Timeouts
Slow migrations and databases that take a while to initialize will hit the default readiness window. Raise it for everything:
x-minienv:
k8s:
context: minikube
namespace: my-branch
deploymentTimeout: 5m
Or for a single service, which is usually the better fit:
services:
migrate:
x-minienv-k8s-service:
deploymentType: job
deploymentTimeout: 10m
Values are duration strings: 30s, 5m, 1h30m. If a deploy times out, the
release is rolled back.
CLI Reference
Commands
| Command | Aliases | Description |
|---|---|---|
minienv deploy | up | Brings up your remote mini environment |
minienv destroy | down | Tears it down |
minienv version | — | Prints version info |
None of them take positional arguments.
Flags
All flags are global and work on every command.
| Flag | Short | Default | Description |
|---|---|---|---|
--file | -f | — | Compose configuration file. Repeatable. Same as docker compose -f |
--project-directory | — | — | Alternate working directory. Same as docker compose --project-directory |
--project-name | -p | — | Project name. Same as docker compose -p |
--dry-run | — | false | Build images without pushing, and preview the deploy without applying it |
The first three behave exactly as they do in docker compose.
What --dry-run still touches depends on the deployer you configured — see
Getting Started.
Environment variables
| Variable | Effect |
|---|---|
NGROK_AUTHTOKEN | Enables ngrok. Unset means all ngrok config is silently ignored — see Exposing Services |
NGROK_API_KEY | Optional. Only used to print the published URL table after a deploy. Not needed to publish, or by minienv down |
HELM_DRIVER | Helm storage backend for the k8s deployer. Defaults to secret |
Standard compose environment handling also applies: COMPOSE_FILE,
COMPOSE_PROJECT_NAME, .env file loading, and ${VAR} interpolation. That
last one is the easiest way to give each developer or branch its own namespace:
x-minienv:
k8s:
context: minikube
namespace: $MINIENV_NAMESPACE
MINIENV_NAMESPACE=alice-feature-x minienv deploy
Examples
# Deploy using the default compose file
minienv deploy
# Deploy with an override file
minienv deploy -f compose.yml -f compose.prod.yml
# See what would happen, without pushing images or changing the target
minienv deploy --dry-run
# Tear down
minienv destroy
Configuration Reference
Every field minienv reads. For explanations and examples, see Configuration.
x-minienv
Top level of the compose file. Configure exactly one of the below deployers — configuring more than one is an error.
k8s
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
k8s.context | string | yes | — | Targets a specific cluster when deploying |
k8s.namespace | string | yes | — | Targets a specific namespace when deploying |
k8s.deploymentTimeout | duration | no | 60s | Helm timeout for all services; overridable per service |
k8s.ngrok.trafficPolicy | string | no | "" | ngrok on_http_request policy applied to every service that does not set its own |
docker
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
docker.namespace | string | yes | — | Compose project name and directory (~/.minienv/<namespace>) on the remote host. Reduced to [a-z0-9_-] |
docker.transport | map | yes | — | How to reach the remote host |
docker.transport.ssh.host | string | yes | — | Target host for the SSH connection |
docker.transport.ssh.identity | string | yes | — | Path to the private key file |
docker.transport.ssh.user | string | no | local user | User for the SSH connection |
docker.transport.ssh.port | integer | no | 22 | Port for the SSH connection |
docker.ngrok.trafficPolicy | string | no | "" | ngrok on_http_request policy applied to every service that does not set its own |
docker.transport
Exactly one transport must be configured; configuring none, like configuring more than one, is an error.
The host key must already be trusted. minienv verifies against
~/.ssh/known_hostson the machine running it and offers no prompt or bypass, so connect once by hand before the first deploy. Encrypted identity files are not supported.
x-minienv-k8s-service
Under services.<name>. All fields are optional.
| Field | Type | Default | Description |
|---|---|---|---|
affinity | k8s schema | — | Passed through to the pod spec |
command | list of strings | compose command | Container command |
configMapFrom | list of strings | — | Files, relative to the compose project directory, that become one ConfigMap named after the service |
deploymentTimeout | duration | inherits top level | Helm timeout for this service |
deploymentType | service | job | service | Deploy as a Deployment or a run-to-completion Job |
env | k8s schema | — | Container env vars as EnvVar entries, merged over compose environment by name |
envFrom | k8s schema | — | Sources to populate container env vars from, as EnvFromSource entries |
image.platforms | list of strings | ["linux/amd64"] | Platforms to build and push |
image.pullPolicy | string | IfNotPresent | Kubernetes image pull policy |
image.repository | string | from compose image | Image repository |
image.tag | string | from compose image | Image tag. +git expands to the short commit SHA |
imagePullSecrets[].name | string | — | Existing pull secret in the namespace. See Private registries |
livenessProbe | k8s schema | from compose healthcheck | Passed through to the container spec |
manifests | list of strings | — | Paths, relative to the compose project directory, to extra manifests rendered into this service’s release |
ngrok.port | integer | — | Container port to expose publicly — the container side of a compose mapping. Ignored for a job, a skipped service, and when NGROK_AUTHTOKEN is unset. See Exposing Services |
ngrok.trafficPolicy | string | inherits top level | ngrok on_http_request policy |
ngrok.url | string | random | Reserved domain for a stable endpoint |
nodeSelector | k8s schema | — | Passed through to the pod spec |
podAnnotations | map of string to string | — | Passed through to the pod template |
podLabels | map of string to string | — | Passed through to the pod template |
podSecurityContext | k8s schema | — | Passed through to the pod spec |
readinessProbe | k8s schema | from compose healthcheck | Passed through to the container spec |
recreate | bool | false | Replace the pods, and any resource that cannot be patched in place, on every deploy. For fixed tags like latest |
replicas | integer | 1 | Number of replicas. No effect on a job |
resources | k8s schema | — | Passed through to the container spec |
securityContext | k8s schema | — | Passed through to the container spec |
service.create | bool | true | Whether to create a Kubernetes Service. Forced to false when the service resolves no ports |
service.ports | list | derived from compose ports | Explicit port mappings, merged with the derived ones |
service.type | string | ClusterIP | Service type |
serviceAccount.annotations | map of string to string | {} | Extra annotations |
serviceAccount.automount | bool | true | Automount the service account token |
serviceAccount.create | bool | true | Whether to create a ServiceAccount. Forced to false when the service resolves no ports |
serviceAccount.name | string | "" | Name. Empty means the generated fullname when creating, otherwise default |
skip | bool | false | Excludes the service from image builds and deploys. destroy still uninstalls it |
startupProbe | k8s schema | from compose healthcheck | Passed through to the container spec |
tolerations | k8s schema | — | Passed through to the pod spec |
volumeMounts | k8s schema | — | Passed through to the container spec |
volumes | k8s schema | — | Passed through to the pod spec |
Fields marked k8s schema reach the generated manifests exactly as written, so
the Kubernetes documentation is the
reference for their shape.
configMapFrom
Each file’s base name is a key and its content is the value. Mount the
ConfigMap with volumes and volumeMounts:
services:
postgres:
image: postgres:15
x-minienv-k8s-service:
configMapFrom:
- db/init/01-schema.sql
- db/init/02-seed.sql
volumes:
- name: init
configMap:
name: postgres
volumeMounts:
- name: init
mountPath: /docker-entrypoint-initdb.d
Content is written as-is, not rendered as a template, and must be UTF-8 text.
Two entries may not share a base name. A change to any file’s content replaces
the pods on the next deploy. A file under manifests must not also produce a
ConfigMap named after the service, since the two would collide in the release.
env
Merged over compose environment by name. A literal value is written into
the manifest, so do not put secrets there if the manifests are visible to
others. Values compose could not resolve (the bare - SOME_VAR form, with
nothing set locally) are dropped rather than set empty.
x-minienv-k8s-service:
env:
- name: EXAMPLE_VAR
value: example-value
- name: EXAMPLE_FROM_VAR
valueFrom:
secretKeyRef:
name: example-secret
key: example-key
manifests
Files deployed as part of the service’s release — a ConfigMap or Secret behind
a volumes entry, an Ingress, a PVC, a Traefik IngressRoute:
services:
api:
image: myorg/api:latest
x-minienv-k8s-service:
manifests:
- k8s/configmap.yaml
volumes:
- name: config
configMap:
name: api-config
volumeMounts:
- name: config
mountPath: /etc/api
Each file is a Helm template. .Values, .Release and .Chart are in scope,
alongside the chart’s generated.name, generated.fullname, generated.chart,
generated.labels, generated.selectorLabels and
generated.serviceAccountName helpers:
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "generated.fullname" . }}-config
labels:
{{- include "generated.labels" . | nindent 4 }}
data:
LOG_LEVEL: debug
Every field in the table above is readable as .Values under the same path —
replicas as .Values.replicas, image.tag as .Values.image.tag — resolved,
so .Values.image.tag carries the tag derived from compose image: with a
+git already expanded.
A job’s values are a smaller set. A
deploymentType: jobchart has noreplicasor probes, so a manifest on a job that reads one renders empty rather than failing.
A manifest is created and removed with its service’s release. Two services must not declare manifests producing the same object — the second release to reach it fails on ownership, and the order between them is not fixed.
Probes
livenessProbe, readinessProbe and startupProbe are derived from a compose
healthcheck. A curl or wget test against http://localhost[:port][/path]
becomes an HTTP probe on the container port; anything else becomes an exec
probe. disable: true, an empty test, or test: ["NONE"] produces none.
Set any of the three to write it yourself. The ones you leave unset still come
from the compose healthcheck:
x-minienv-k8s-service:
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 5
Caution: a
curlorwgettest pointed at anything other thanlocalhostproduces no probes at all — silently. Uselocalhost, write the check as a non-HTTP command, or set the probes yourself.
service.ports
Each entry requires all three fields:
| Field | Type | Description |
|---|---|---|
containerPortName | string | Name of the port, on the container and Service |
containerPort | integer | Port the container listens on |
protocol | string | TCP or UDP |
Ports derived from compose take the container side of the mapping, are named
p<port> — p8080 for container port 8080 — and default to protocol TCP.
x-minienv-docker-service
Under services.<name>. All fields are optional.
| Field | Type | Default | Description |
|---|---|---|---|
copy[].containerPath | string | — | Absolute path inside the container to mount the copied path at |
copy[].hostPath | string | — | Path, relative to the compose project directory, of a file or directory to copy |
image.platforms | list of strings | ["linux/amd64"] | Platforms to build and push |
image.repository | string | from compose image | Image repository |
image.tag | string | from compose image | Image tag. +git expands to the short commit SHA |
ngrok.port | integer | — | Container port to expose publicly — the container side of a compose mapping. Ignored for a skipped service and when NGROK_AUTHTOKEN is unset. See Exposing Services |
ngrok.trafficPolicy | string | inherits top level | ngrok on_http_request policy |
ngrok.url | string | random | Reserved domain for a stable endpoint |
skip | bool | false | Excludes the service from image builds and deploys |
copy
Files and directories sent to the remote host and bind mounted into the container — a config file, a TLS certificate, a seed dataset, a directory of fixtures:
services:
api:
image: myorg/api:latest
x-minienv-docker-service:
copy:
- hostPath: conf/api.yml
containerPath: /etc/api/api.yml
- hostPath: seed
containerPath: /var/lib/seed
A directory is copied recursively. hostPath must stay inside the project — an
absolute path, a path climbing out with .., and the project directory itself
are all rejected. containerPath must be absolute, and two entries on one
service cannot name the same one.
The copied paths land under the deployment’s own directory on the remote host,
each keeping the relative path it was declared with, so two files that share a
base name stay apart. Two services naming the same hostPath share one copy. A
service marked skip is not deployed, so nothing it declares is copied.
The whole set is replaced on every deploy. An entry you remove or rename takes its remote copy with it on the next deploy, and
minienv destroyremoves the deployment directory that holds all of them.
Machine-readable schema
The authoritative schema lives at schema/schema.json in the repository. It
wraps the official compose spec and adds the three extension fields
(x-minienv, x-minienv-k8s-service and x-minienv-docker-service). Reference
it from your compose file for editor autocomplete:
# $schema: https://raw.githubusercontent.com/robgonnella/minienv/refs/heads/main/schema/schema.json
Deployers
The x-minienv block is keyed by target: configure exactly one. Pick the one
whose prerequisites you already have.
Kubernetes
Deploys each compose service as its own Helm release. There is no chart to maintain in your repository.
Needs a reachable Kubernetes context:
x-minienv:
k8s:
context: minikube
namespace: my-branch
Per-service overrides go under x-minienv-k8s-service.
Generated resources
Per compose service:
| Resource | When |
|---|---|
| Deployment | deploymentType: service (the default) |
| Job | deploymentType: job |
| Service | deploymentType: service and service.create is true |
| ServiceAccount | serviceAccount.create is true |
| ConfigMap | configMapFrom names at least one file |
Once per namespace:
| Resource | When |
|---|---|
ngrok release: Deployment, ConfigMap, Secret, ServiceAccount | any service publishes via ngrok |
The target namespace is created on deploy if it does not already exist.
Nothing else is derived from the compose file — no Ingress,
PersistentVolumeClaim, HorizontalPodAutoscaler or PodDisruptionBudget, and
compose environment is rendered inline rather than into a ConfigMap or
Secret. Anything else a service needs is declared explicitly, with
configMapFrom or manifests.
Docker
Deploys the whole project to a single remote host as one docker compose
project, over a transport.
Needs an SSH-reachable host running docker and its compose plugin:
x-minienv:
docker:
namespace: my-branch
transport:
ssh:
host: dev-box.example.com
identity: ~/.ssh/id_ed25519
Per-service overrides go under x-minienv-docker-service.
What reaches the remote host
Before the project is sent, minienv:
| Change | Why |
|---|---|
Renames the project to namespace | Keeps two environments on one host from colliding |
Drops services marked skip | Along with any depends_on edge pointing at them |
Clears build: | Images are built locally and pulled by tag on the remote |
Clears ports: | Nothing is published to the remote host’s network |
| Drops bind mounts | The host paths do not exist there. Named volumes are kept |
Adds a bind mount per copy entry | Pointed at where that entry is copied on the host |
Drops env_file and label_file | Their values are already in environment and labels |
Pins image: | To the repository and tag resolved for this run |
Injects an ngrok service | Only when something publishes |
Everything else — environment, healthcheck, command, depends_on,
networks, named volumes — is passed through as written, already fully
interpolated.
Every field of both blocks is listed in Configuration Reference.
Troubleshooting
Read the error first — this page is for what minienv does not tell you.
Nothing is exposed publicly
NGROK_AUTHTOKEN is not set. When it is missing, every ngrok block is
discarded without a warning and the deploy still succeeds. Check this first.
No URL table prints after a successful deploy
An unset or invalid NGROK_API_KEY. That is a different credential from
NGROK_AUTHTOKEN and only the table needs it, so the environment is already up.
The URL table shows my-namespace-api rather than api
That is the ngrok endpoint name, prefixed with the namespace so it is unique on the account. It is also the name to look for in the ngrok dashboard.
A published URL changed after a redeploy
An endpoint with no reserved ngrok.url keeps its address only while the ngrok
agent survives. Changing the set of published services — or rotating the auth
token — replaces the agent, which reassigns every unreserved URL at once.
A reserved domain is the only way to hold an address — see Exposing Services.
A new service is not published, or a removed one still is
If a URL looks stale, check whether the agent actually restarted.
On Kubernetes:
kubectl get pods -n <namespace> -l app.kubernetes.io/name=ngrok
On Docker:
ssh <host> 'cd ~/.minienv/<namespace> && docker compose ps ngrok'
See Exposing Services.
The ngrok agent will not start
Check whether ngrok.url names a domain your account actually holds. The agent
refuses a domain that is not reserved, and names the one it could not claim in
its own logs.
On Kubernetes the whole deploy fails at its last step, and rolls back:
kubectl logs -n <namespace> -l app.kubernetes.io/name=ngrok
On Docker the rest of the project is already up and only the agent is unhealthy:
ssh <host> 'cd ~/.minienv/<namespace> && docker compose logs ngrok'
Two developers cannot both publish the same service
Check whether ngrok.url is a hardcoded domain. It is committed to
compose.yml and is therefore the same for everyone, and two agents cannot
claim one domain at once. Interpolate the namespace into it:
url: https://${MINIENV_NAMESPACE}-api.example.ngrok.app
A redeploy did not pick up my rebuilt image
The image tag did not change, so neither target saw a reason to replace the
container. Use a tag that moves — +git appends the current short sha. See
Images and Builds.
On Kubernetes you can instead force a replacement on every deploy with
recreate: true on the service.
A code change does not show up at all
Either the build was skipped or the target is reusing a cached image.
A service with a build: section is skipped when minienv cannot work out a
complete image reference — registry, tag, context and dockerfile. It logs
missing required fields: skipping build and push, then deploys whatever image
reference it does have. That is a warning, not an error.
For a cached image, a +git tag gives every commit a unique one. See
Images and Builds.
A setting under a service extension has no effect
- The extension does not match the active deployer. Each deployer reads only its own, and ignores any other silently. Check this first after switching targets.
- The key is misspelled. Unknown keys are ignored at runtime; the JSON schema is stricter and would have flagged it in your editor.
- The key is one the target ignores — see what a job ignores.
- The key is nested wrongly. The extension is mostly flat:
replicasandresourcessit directly underx-minienv-k8s-service.
A compose setting has no effect
On Kubernetes, minienv reads a subset of compose: entrypoint, volumes,
user and others have an extension equivalent instead. On Docker, check whether
it is a bind mount or a file:-based secret, which cannot follow the project to
another host — a bind mount can be carried across with copy. Both are covered
in
supplying what compose does not carry.
A health check never runs
A curl or wget healthcheck against anything other than http://localhost
produces no probes at all. Point it at localhost, using the container
port.
A service has no Kubernetes Service
A service with no ports gets no Service and no ServiceAccount. Add a port
mapping to compose ports: if you expected one.
minienv cannot reach the remote host
For the docker deployer, the host key must already be in your
~/.ssh/known_hosts. minienv verifies it and offers no prompt or bypass, so
connect once by hand before the first deploy. Encrypted identity files are not
supported.
Teardown failed and left containers behind
The directory holding your configuration and credentials is removed even when
teardown fails. Containers or volumes may survive it; check on the host with
docker ps.