Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. Builds and pushes images for any service with a build: section.
  2. Prepares the target — creating the Kubernetes namespace, or the remote directory the compose project will live in.
  3. Deploys your services, respecting depends_on order.
  4. 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

Two targets, one at a time. k8s deploys each service as a Helm release to a cluster; docker deploys the whole project to a single remote host. See Deployers.

License

Licensed under either of

Getting Started

Prerequisites

RequirementWhen you need it
docker with buildxOnly for services that have a build: section
gitOnly if you use a +git image tag
A reachable Kubernetes contextOnly for the k8s deployer
An SSH-reachable host with docker and its compose pluginOnly 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 k8s dry 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

minienv adds one top-level field to your compose file, plus a per-service field for whichever target you picked.

FieldWherePurpose
x-minienvtop levelChooses and configures the deployment target
x-minienv-k8s-serviceunder a servicePer-service overrides, Kubernetes target
x-minienv-docker-serviceunder a servicePer-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 keySet in x-minienv-k8s-service
entrypointcommand
volumesvolumes and volumeMounts
configsconfigMapFrom, mounted through volumes
secretsmanifests, mounted through volumes
deploy.replicasreplicas
labelspodLabels
usersecurityContext

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

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 — set image.repository and image.tag in 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 manifests in 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_AUTHTOKEN is 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.app

A 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

CommandAliasesDescription
minienv deployupBrings up your remote mini environment
minienv destroydownTears it down
minienv versionPrints version info

None of them take positional arguments.

Flags

All flags are global and work on every command.

FlagShortDefaultDescription
--file-fCompose configuration file. Repeatable. Same as docker compose -f
--project-directoryAlternate working directory. Same as docker compose --project-directory
--project-name-pProject name. Same as docker compose -p
--dry-runfalseBuild 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

VariableEffect
NGROK_AUTHTOKENEnables ngrok. Unset means all ngrok config is silently ignored — see Exposing Services
NGROK_API_KEYOptional. Only used to print the published URL table after a deploy. Not needed to publish, or by minienv down
HELM_DRIVERHelm 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

FieldTypeRequiredDefaultDescription
k8s.contextstringyesTargets a specific cluster when deploying
k8s.namespacestringyesTargets a specific namespace when deploying
k8s.deploymentTimeoutdurationno60sHelm timeout for all services; overridable per service
k8s.ngrok.trafficPolicystringno""ngrok on_http_request policy applied to every service that does not set its own

docker

FieldTypeRequiredDefaultDescription
docker.namespacestringyesCompose project name and directory (~/.minienv/<namespace>) on the remote host. Reduced to [a-z0-9_-]
docker.transportmapyesHow to reach the remote host
docker.transport.ssh.hoststringyesTarget host for the SSH connection
docker.transport.ssh.identitystringyesPath to the private key file
docker.transport.ssh.userstringnolocal userUser for the SSH connection
docker.transport.ssh.portintegerno22Port for the SSH connection
docker.ngrok.trafficPolicystringno""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_hosts on 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.

FieldTypeDefaultDescription
affinityk8s schemaPassed through to the pod spec
commandlist of stringscompose commandContainer command
configMapFromlist of stringsFiles, relative to the compose project directory, that become one ConfigMap named after the service
deploymentTimeoutdurationinherits top levelHelm timeout for this service
deploymentTypeservice | jobserviceDeploy as a Deployment or a run-to-completion Job
envk8s schemaContainer env vars as EnvVar entries, merged over compose environment by name
envFromk8s schemaSources to populate container env vars from, as EnvFromSource entries
image.platformslist of strings["linux/amd64"]Platforms to build and push
image.pullPolicystringIfNotPresentKubernetes image pull policy
image.repositorystringfrom compose imageImage repository
image.tagstringfrom compose imageImage tag. +git expands to the short commit SHA
imagePullSecrets[].namestringExisting pull secret in the namespace. See Private registries
livenessProbek8s schemafrom compose healthcheckPassed through to the container spec
manifestslist of stringsPaths, relative to the compose project directory, to extra manifests rendered into this service’s release
ngrok.portintegerContainer 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.trafficPolicystringinherits top levelngrok on_http_request policy
ngrok.urlstringrandomReserved domain for a stable endpoint
nodeSelectork8s schemaPassed through to the pod spec
podAnnotationsmap of string to stringPassed through to the pod template
podLabelsmap of string to stringPassed through to the pod template
podSecurityContextk8s schemaPassed through to the pod spec
readinessProbek8s schemafrom compose healthcheckPassed through to the container spec
recreateboolfalseReplace the pods, and any resource that cannot be patched in place, on every deploy. For fixed tags like latest
replicasinteger1Number of replicas. No effect on a job
resourcesk8s schemaPassed through to the container spec
securityContextk8s schemaPassed through to the container spec
service.createbooltrueWhether to create a Kubernetes Service. Forced to false when the service resolves no ports
service.portslistderived from compose portsExplicit port mappings, merged with the derived ones
service.typestringClusterIPService type
serviceAccount.annotationsmap of string to string{}Extra annotations
serviceAccount.automountbooltrueAutomount the service account token
serviceAccount.createbooltrueWhether to create a ServiceAccount. Forced to false when the service resolves no ports
serviceAccount.namestring""Name. Empty means the generated fullname when creating, otherwise default
skipboolfalseExcludes the service from image builds and deploys. destroy still uninstalls it
startupProbek8s schemafrom compose healthcheckPassed through to the container spec
tolerationsk8s schemaPassed through to the pod spec
volumeMountsk8s schemaPassed through to the container spec
volumesk8s schemaPassed 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: job chart has no replicas or 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 curl or wget test pointed at anything other than localhost produces no probes at all — silently. Use localhost, write the check as a non-HTTP command, or set the probes yourself.

service.ports

Each entry requires all three fields:

FieldTypeDescription
containerPortNamestringName of the port, on the container and Service
containerPortintegerPort the container listens on
protocolstringTCP 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.

FieldTypeDefaultDescription
copy[].containerPathstringAbsolute path inside the container to mount the copied path at
copy[].hostPathstringPath, relative to the compose project directory, of a file or directory to copy
image.platformslist of strings["linux/amd64"]Platforms to build and push
image.repositorystringfrom compose imageImage repository
image.tagstringfrom compose imageImage tag. +git expands to the short commit SHA
ngrok.portintegerContainer 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.trafficPolicystringinherits top levelngrok on_http_request policy
ngrok.urlstringrandomReserved domain for a stable endpoint
skipboolfalseExcludes 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 destroy removes 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:

ResourceWhen
DeploymentdeploymentType: service (the default)
JobdeploymentType: job
ServicedeploymentType: service and service.create is true
ServiceAccountserviceAccount.create is true
ConfigMapconfigMapFrom names at least one file

Once per namespace:

ResourceWhen
ngrok release: Deployment, ConfigMap, Secret, ServiceAccountany 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:

ChangeWhy
Renames the project to namespaceKeeps two environments on one host from colliding
Drops services marked skipAlong 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 mountsThe host paths do not exist there. Named volumes are kept
Adds a bind mount per copy entryPointed at where that entry is copied on the host
Drops env_file and label_fileTheir values are already in environment and labels
Pins image:To the repository and tag resolved for this run
Injects an ngrok serviceOnly 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

  1. 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.
  2. The key is misspelled. Unknown keys are ignored at runtime; the JSON schema is stricter and would have flagged it in your editor.
  3. The key is one the target ignores — see what a job ignores.
  4. The key is nested wrongly. The extension is mostly flat: replicas and resources sit directly under x-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.