A fully air-gapped Elastic Stack lab for Docker Desktop Kubernetes. Deploy Elasticsearch, Kibana, Fleet, Logstash, and Maps with zero internet at run time. Uses Elastic Cloud on Kubernetes (ECK) with a local package registry, cached GeoIP databases, and pre-downloaded ML models so the full stack runs offline after a single online asset pull.
A fully air-gapped Elastic Stack lab: deploy Elasticsearch, Kibana, Fleet, Logstash, and Maps on Docker Desktop Kubernetes with zero internet required at run time.
Elastic Airgapped is a single-laptop lab for engineers who need the full Elastic Stack offline: demos on a plane, field work without connectivity, secure-environment testing, or just a reproducible local cluster that does not reach out to the internet once deployed. Pull every asset once — images, Helm charts, Elastic Cloud on Kubernetes (ECK) manifests, GeoIP databases, ML models, and the Fleet package registry — then deploy and run with the network off.
Running Elasticsearch locally is easy; running the full stack without internet is not. Fleet, integrations, ML models, GeoIP databases, and the ECK operator all reach out to elastic.co endpoints by default. A real air-gapped lab has to cache every one of those dependencies and rewire the cluster to use the local copies.
This project does all of that for you with a single make pull-all while you are online, then lets you run the stack end-to-end while offline.
Who this is for: solutions engineers preparing offline demos, security teams validating Elastic in disconnected environments, developers on unreliable connectivity, and anyone who wants a reproducible, self-contained local Elastic cluster.
.env variable. Enterprise accepts a license JSON file.make deploy provisions namespaces, installs the ECK operator, activates your license, and rolls out the stack.make clean-all removes the stack, the operator, the ingress controller, and leaves your Docker daemon otherwise untouched.make save-images writes every required container image to assets/images/*.tar so the asset bundle can be transported to another machine.*.localhost hostnames that resolve without touching /etc/hosts on Chrome and Firefox. Safari gets a make add-hosts helper.make deploy / make clean-all, and a pre-wired Kibana AI Connector targets it on the host — so the Kibana AI Assistant works with a private model and no internet, with the same lifecycle commands as the rest of the stack.Docker Desktop installed and Kubernetes enabled? Run:
# 1. Clone
git clone https://github.com/jamesagarside/elastic-airgapped.git
cd elastic-airgapped
# 2. Copy the env template
cp .env.example .env
# 3. (Online) Cache every dependency
make pull-all
# 4. (Offline is fine now) Deploy
make deploy
# 5. Add /etc/hosts entries for Safari (Chrome/Firefox skip this)
make add-hosts
# 6. Open
open https://kibana.localhost
Username: elastic. Password: retrieve with kubectl get secret elastic-lab-es-elastic-user -n elastic -o go-template=''.
You need:
containerd image store enabled in Docker Desktop (Settings -> General -> Use containerd for pulling and storing images). Without it, you also need kind installed and must run make load-images after make pull-images.kubectl on PATH.make check-tools will install helm and gettext if they are missing.make pull-all only. Everything after that runs offline.Verify your environment:
make check-env
make show-config
One command downloads every asset the stack needs at run time:
make pull-all
This runs in sequence:
| Step | What it pulls | Destination |
|---|---|---|
pull-assets |
ECK CRDs + operator YAML, ingress-nginx Helm chart | assets/eck/, assets/charts/ |
pull-geoip |
GeoLite2 City / Country / ASN .mmdb databases |
assets/geoip/ |
pull-ml-models |
ELSER v2 tokenizer + model weights | assets/ml-models/ |
pull-epr |
Elastic Package Registry distribution image | local Docker daemon |
pull-images |
All stack images: Elasticsearch, Kibana, Agent, Logstash, Maps, ECK operator, ingress-nginx | local Docker daemon |
Want a portable bundle (for moving to a different offline machine)?
make save-images # writes assets/images/*.tar
Then transport the whole assets/ directory to the target machine and run make load-images-tarball before make deploy.
Verify the cache before disconnecting:
make verify-offline
make deploy
make deploy will:
.env (including license tier).assets/eck/..env (Trial, Basic, or Enterprise).LLM_CONNECTOR_MODEL (skipped if that variable is empty in .env; LM Studio is installed via Homebrew if not already present).manifests/, substituting .env values via envsubst.health: green.Set the license tier in .env:
# One of: trial | basic | enterprise
ECK_LICENSE_TIER=trial
# Only used when ECK_LICENSE_TIER=enterprise
ECK_LICENSE_FILE=/path/to/eck-enterprise-license.json
| Tier | What you get | How it is applied |
|---|---|---|
| trial | 30 days of Enterprise features (ML, alerts, AI Assistant, etc.). Can be started once per cluster. | kubectl apply of a Secret labelled license.k8s.elastic.co/type: enterprise_trial. |
| basic | Free tier. No action required. Any existing trial/enterprise secret is removed. | kubectl delete secret eck-trial-license eck-license in the ECK namespace. |
| enterprise | Full, paid Enterprise features. | kubectl create secret generic eck-license --from-file=<LICENSE_FILE> with license.k8s.elastic.co/scope=operator. |
Apply the license independently of make deploy:
make apply-license # applies per ECK_LICENSE_TIER
make check-license # shows current license secret + elastic-licensing ConfigMap
make clean-license # removes all license secrets (reverts to basic)
See the official ECK licensing docs for the source material.
Default hostnames (set in .env):
| Service | URL | Notes |
|---|---|---|
| Kibana | https://kibana.localhost |
main UI |
| Elasticsearch | https://elasticsearch.localhost |
REST API |
Get the elastic user password:
kubectl get secret elastic-lab-es-elastic-user \
-n elastic \
-o go-template=''
Tail operator or stack logs:
kubectl -n elastic-system logs -l control-plane=elastic-operator -f
kubectl -n elastic get elasticsearch,kibana,agent,logstash,elasticmapsserver
make help # list every target
# Online, one-time
make pull-all # assets + images + GeoIP + ML models + EPR
make save-images # export images to assets/images/*.tar (for portability)
# Offline
make deploy # full deploy (ECK -> license -> ingress -> LLM -> stack)
make apply-license # re-apply license per .env
make add-hosts # /etc/hosts entries for Safari
# Local LLM (optional, host-side via LM Studio)
make check-lms # ensure the lms CLI is installed (installs LM Studio via Homebrew if missing)
make start-llm # start LM Studio server and load $LLM_CONNECTOR_MODEL
make check-llm # probe the endpoint and confirm the model is loaded
make stop-llm # unload models and stop the LM Studio server
# Inspect
make show-config # pretty-print resolved .env
make check-env # validate .env
make diff-env # .env vs .env.example
make verify-offline # are all asset caches present?
make check-license # current license state
# Teardown
make clean-elastic # remove stack (keeps ECK operator)
make clean-ingress # remove ingress-nginx
make clean-eck # remove ECK operator + CRDs
make clean-all # all of the above + stop LM Studio
elastic-airgapped/
├── .env.example # config template
├── Makefile # every workflow lives here
├── manifests/ # Kubernetes manifests (envsubst-templated)
│ ├── elasticsearch.yaml
│ ├── kibana.yaml
│ ├── fleet-server.yaml
│ ├── agent.yaml
│ ├── logstash.yaml
│ ├── maps-server.yaml
│ ├── package-registry.yaml # local EPR (air-gapped Fleet integrations)
│ ├── ingress.yaml
│ └── network-policy.yaml
├── assets/ # populated by `make pull-all` (gitignored)
│ ├── eck/ # CRDs + operator.yaml
│ ├── charts/ # ingress-nginx helm chart (.tgz)
│ ├── images/ # optional: `make save-images` tarballs
│ ├── geoip/ # GeoLite2 .mmdb files
│ └── ml-models/ # ELSER model artefacts
└── .github/workflows/ # CI, Pages, release tracking
elastic-system watches the custom resources (Elasticsearch, Kibana, Agent, Logstash, ElasticMapsServer) in the elastic namespace and reconciles them.kibana.localhost and elasticsearch.localhost, routing through the cluster to the stack services.Agent with fleetServerEnabled: true. A single ingest Agent enrols against it.distribution:lite EPR is deployed inside the cluster; Kibana is configured with xpack.fleet.registryUrl pointing at the in-cluster Service, so Fleet integrations install without reaching epr.elastic.co.ingest.geoip.downloader.enabled: false is set in the Elasticsearch config, and the make pull-geoip target downloads GeoLite2 databases into assets/geoip/ for optional upload via the GeoIP processor API.assets/ml-models/ and can be uploaded to the ML node once the cluster is up.host.docker.internal. Apple Silicon’s GPU is not visible to Docker Desktop’s Kubernetes VM, so the model stays on the host (Metal/MLX) while make deploy / make clean-all drive its lifecycle through the lms CLI — keeping the cluster reproducible and the inference fast. Fully offline and private.What “air-gapped” actually means here:
| Concern | Status |
|---|---|
| Container images | Pulled to local Docker daemon; optionally saved to tarball for portability. |
| ECK operator manifests + CRDs | Cached in assets/eck/. |
| Ingress Helm chart | Cached in assets/charts/. |
| GeoIP auto-download | Disabled in Elasticsearch config. Databases cached in assets/geoip/ for optional manual upload. |
| Fleet integrations | Served by a local Elastic Package Registry deployed inside the cluster. |
| ML models (ELSER) | Cached in assets/ml-models/ and uploadable via the ML trained-models API. |
| Detection rule updates | Auto-updates are an internet call; disable in Kibana or ignore on an offline lab. |
| Endpoint artefact updates | Same — manual or disabled. |
| Enterprise license | User-supplied JSON, applied as a Kubernetes Secret. |
kubectl describe pod -n elastic <pod>
Usually means Docker Desktop has not allocated enough memory. Bump it in Settings -> Resources.
kubectl get events -n elastic --sort-by=.lastTimestamp | tail
If you see ErrImagePull, images are not in the local Docker daemon. Either you did not run make pull-images, or Docker Desktop is not using the containerd image store. Enable containerd (Settings -> General) or run make load-images to push images into a kind node instead.
make deploy already retries six times with a 10-second backoff — the ingress-nginx admission webhook needs a few seconds to come up. If it still fails, re-run make deploy.
make clean-*The clean targets strip finalizers and force-delete automatically. If a namespace still lingers:
kubectl patch namespace elastic \
--type=merge -p '{"metadata":{"finalizers":[]}}'
.env drifted from .env.examplemake diff-env
Shows missing and stale keys, with each key’s default value.
Yes, once make pull-all has run. The runtime cluster has no outbound traffic: GeoIP auto-download is disabled, Fleet uses a local Elastic Package Registry, and every image is already in your Docker daemon. The only caveat is that the Docker Desktop daemon itself needs to be running — Docker Desktop’s update check is internal to the Docker process, not the cluster.
Yes. Docker Desktop for Mac and Windows both work. On Linux you can use Docker Desktop for Linux or adapt the Makefile to use kind directly (the load-images target already exists for that).
12 GB allocated to Docker Desktop is the realistic minimum for the full stack (Elasticsearch x2, ML node, Kibana, Fleet, Agent, Logstash, Maps, EPR). You can reduce the ML node to 0 replicas in manifests/elasticsearch.yaml if you do not need ML, which drops the requirement to roughly 8 GB.
No. The default tier is trial (30 days of Enterprise features). After the trial you can continue on Basic, which is the free tier and covers Elasticsearch, Kibana, and Fleet. Enterprise is only needed for paid features like the full AI Assistant, Watcher, document-level security, and cross-cluster replication.
Yes — that is the whole point of the local Elastic Package Registry deployment. Fleet in Kibana browses integrations from the in-cluster EPR, not from epr.elastic.co. New integrations ship with each EPR image tag, so pulling a fresh image is how you update the integration catalogue in your offline lab.
The ML node runs offline with no problem. Pre-trained models (ELSER, E5) normally download from ml-models.elastic.co on first use — make pull-ml-models caches those artefacts locally for manual upload via the PUT _ml/trained_models API.
Yes. Run make pull-all && make save-images, copy the assets/ directory to the target machine, and run make load-images-tarball && make deploy.
Bump ELASTIC_VERSION in .env, run make pull-all online (to cache the new images), then re-run make deploy offline. ECK handles rolling upgrades.
Kibana is templated with an AI Connector pointing at host.docker.internal:<LM_STUDIO_PORT>/v1 — an OpenAI-compatible endpoint. The model itself runs on the host through LM Studio so it can use Metal/MLX (Docker Desktop’s Kubernetes VM cannot see the Apple Silicon GPU), and the lab manages its lifecycle through the same Makefile as the rest of the stack:
make deploy runs make start-llm, which calls make check-lms to install LM Studio via Homebrew if needed (brew install --cask lm-studio + lms bootstrap), starts the lms server on LM_STUDIO_PORT, loads LLM_CONNECTOR_MODEL, and probes the endpoint with make check-llm.make clean-all runs make stop-llm, which unloads models and stops the lms server.LLM_CONNECTOR_MODEL in .env is the single source of truth for the model name: it is passed to lms load and substituted into the Kibana connector’s defaultModel, so changing it in one place updates both sides.LLM_CONNECTOR_MODEL= (empty) in .env to skip the LLM entirely — make deploy becomes a no-op for the LM Studio steps.The Kibana AI Assistant then uses that local endpoint without ever leaving your machine.
ECK is how Elastic officially orchestrates the stack on Kubernetes. It handles TLS, upgrades, node roles, scaling, and stack version upgrades natively. Docker Compose works too — see the sibling project elastic-at-home for a Compose-based home SIEM.
Apache 2.0. See LICENSE.
Issues and PRs welcome at github.com/jamesagarside/elastic-airgapped.