Elastic Airgapped - Deploy the Elastic Stack Offline on Docker Desktop Kubernetes

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.

View the Project on GitHub jamesagarside/elastic-airgapped

Elastic Airgapped

License Latest release GitHub stars Last commit Elastic Stack ECK Kubernetes Docker Desktop

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.


Table of Contents


Why Elastic Airgapped?

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.

Features


Quick Start

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=''.


Prerequisites

You need:

Verify your environment:

make check-env
make show-config

Pull Assets While Online

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

Deploy Offline

make deploy

make deploy will:

  1. Validate .env (including license tier).
  2. Install the ECK operator from assets/eck/.
  3. Apply the license you selected in .env (Trial, Basic, or Enterprise).
  4. Install ingress-nginx from the local Helm chart.
  5. Start LM Studio on the host and load LLM_CONNECTOR_MODEL (skipped if that variable is empty in .env; LM Studio is installed via Homebrew if not already present).
  6. Apply every manifest under manifests/, substituting .env values via envsubst.
  7. Wait for Elasticsearch to report health: green.

License Tiers (Trial / Basic / Enterprise)

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.


Access the Stack

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

Makefile Reference

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

Project Structure

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

Architecture


Air-gap Considerations

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.

Troubleshooting

Pods stuck Pending

kubectl describe pod -n elastic <pod>

Usually means Docker Desktop has not allocated enough memory. Bump it in Settings -> Resources.

ImagePullBackOff

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.

Ingress webhook TLS error on first deploy

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.

Stuck namespace on 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.example

make diff-env

Shows missing and stale keys, with each key’s default value.


FAQ

Does this really work with no internet?

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.

Can I use this on Linux or Windows?

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).

How much RAM do I need?

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.

Do I need a license?

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.

Does Fleet work offline?

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.

Will ML / ELSER work offline?

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.

Can I transport the asset bundle to a different machine?

Yes. Run make pull-all && make save-images, copy the assets/ directory to the target machine, and run make load-images-tarball && make deploy.

How do I upgrade the stack version?

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.

How does the local LLM integration work?

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:

The Kibana AI Assistant then uses that local endpoint without ever leaving your machine.

Why ECK instead of Docker Compose?

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.


License

Apache 2.0. See LICENSE.

Contributing

Issues and PRs welcome at github.com/jamesagarside/elastic-airgapped.