Lattice supports a containerized server deployment. This is the recommended easy path for the control plane because the server does not need host privileges: it owns auth, state, dashboard serving, plugin verification, task queue, and audit.
lattice-node-agent should normally remain a systemd-managed host binary. The
agent reads host facts/logs and may apply reviewed host mutations such as nft,
CoreDNS, sing-box/xray config, or agent self-updates. Running that safely inside
Docker usually requires host mounts or privileged mode, which weakens the
boundary Docker was meant to provide.
cd Lattice/lattice/compose
cp .env.example .env
$EDITOR .env
mkdir -p data plugins
docker compose up -dThe default Compose file uses ghcr.io/latticenet/lattice-server:latest, which
is published from stable v* release tags. Use
ghcr.io/latticenet/lattice-server:alpha for the moving alpha test channel, or
pin a specific version tag/digest for production change control.
The compose file binds the server to localhost only:
127.0.0.1:8088 -> container:8088Expose it with NGINX, Caddy, Cloudflare Tunnel, or a WireGuard-only reverse
proxy. Public internet should reach only HTTPS 443, not the raw server port.
Set at minimum:
LATTICE_ADMIN_PASSWORD=replace-with-a-long-random-password
LATTICE_PUBLIC_URL=https://lattice.example.com
LATTICE_SECURE_COOKIES=1LATTICE_ADMIN_PASSWORD is read only when the initial admin account is
created. After data/state.json exists, changing the environment variable and
restarting the container does not rotate the password. Sign in and rotate it
through POST /api/auth/password instead; the change invalidates existing
sessions and requires a fresh login.
Set LATTICE_TRUST_PROXY=1 only when the container is reachable exclusively
through a trusted reverse proxy that sets CF-Connecting-IP or
X-Forwarded-For. Do not enable it for direct exposure.
The container stores all durable state under /var/lib/lattice, mounted from
./data by the compose file.
On first boot, leave LATTICE_MASTER_KEY_FILE unset. The server will generate
data/master.key automatically with 0600 permissions. Set
LATTICE_MASTER_KEY_FILE only when you are mounting a pre-existing key from a
secret manager or restoring from backup.
The image starts through a small root entrypoint that fixes ownership of the
mounted data directory, then drops privileges to the unprivileged lattice
user. This lets the root-created ./data directory from a normal Compose
bootstrap work without running the server itself as root.
Back up these together:
data/state.jsondata/state.json.audit-waldata/state.json.audit-anchordata/logs.dbdata/master.key
master.key protects stored secrets. Losing it makes encrypted values
unrecoverable.
The compose file mounts:
./plugins -> /plugins:roEach plugin bundle currently has this local layout:
plugins/
example.plugin/
manifest.json
artifactThe server verifies every bundle at startup against the configured trust policy.
Lifecycle state is stored in state.json; the bundle bytes remain in
LATTICE_PLUGIN_DIR.
lattice-server, lattice-sdk, and lattice-dashboard are separate
repositories. The Dockerfile therefore uses BuildKit named contexts:
cd Lattice
docker buildx build --load \
-f lattice-server/Dockerfile \
--build-context lattice-sdk=./lattice-sdk \
--build-context lattice-dashboard=./lattice-dashboard \
--build-arg DASHBOARD_COMMIT="$(cat lattice-server/dashboard.ref)" \
-t lattice-server:local \
./lattice-serverIf docker buildx is not installed locally, install Docker's buildx component
or rely on the GitHub Actions container workflow, which provisions buildx before
building multi-arch images.
The published server image embeds the dashboard commit pinned in
lattice-server/dashboard.ref. To intentionally roll a dashboard-only change
into a new server image, first merge/push lattice-dashboard, then update
dashboard.ref in lattice-server to that dashboard commit and push
lattice-server. The server container workflow will publish a new image from
the new server commit, preserving reproducible image tags.
Run it:
docker run --rm \
-p 127.0.0.1:8088:8088 \
-e LATTICE_ADMIN_PASSWORD='replace-with-a-long-random-password' \
-e LATTICE_PUBLIC_URL='https://lattice.example.com' \
-e LATTICE_PLUGIN_BUNDLE_CACHE_DIR='/var/lib/lattice/plugin-bundles' \
-e LATTICE_PLUGIN_RUNTIME_DIR='/var/lib/lattice/plugin-runtime' \
-v "$PWD/lattice/compose/data:/var/lib/lattice" \
-v "$PWD/lattice/compose/plugins:/plugins:ro" \
lattice-server:localLATTICE_PLUGIN_BUNDLE_CACHE_DIR is required for manifest-v2 plugins. The
loader verifies the signed archive before extracting it into a private,
content-addressed directory under this path. Keep it separate from /plugins,
which remains a read-only source of signed manifests and artifacts.
LATTICE_PLUGIN_RUNTIME_DIR enables the Tier-2 system runner so verified
system-plugin artifacts execute in isolated per-plugin working directories
instead of staying behind the noop runner. Leave it unset to preserve the
fail-closed default. Plugins receive only the runner's fixed safe environment by
default; set LATTICE_PLUGIN_RUNTIME_ENV to a comma/space-separated allowlist
only when a trusted system plugin explicitly needs selected host variables.
The lattice-server repository publishes:
ghcr.io/latticenet/lattice-serverStable releases publish:
image: ghcr.io/latticenet/lattice-server:latest
image: ghcr.io/latticenet/lattice-server:v0.3.0Alpha testing uses:
image: ghcr.io/latticenet/lattice-server:alphaUse immutable tags or digests for unattended production rollouts:
image: ghcr.io/latticenet/lattice-server:v0.3.0
# or
image: ghcr.io/latticenet/lattice-server@sha256:<digest>There is intentionally no main image channel. Source pushes run CI; image
publication is tag-driven.
Internet
-> Cloudflare / NGINX / Caddy on 443
-> 127.0.0.1:8088 lattice-server container
Nodes
-> outbound HTTPS to Lattice server
-> systemd lattice-agent on the hostThe server container should not need:
--privileged- host network
- Docker socket
/host mounts
If a future feature appears to require any of those for lattice-server, treat
that as a design smell and move the host mutation to lattice-node-agent.
After docker compose up -d, verify the localhost service first:
curl -fsS http://127.0.0.1:8088/api/healthIt should return:
{"status":"ok"}Use NGINX as a local HTTPS reverse proxy:
server {
listen 80;
listen [::]:80;
server_name lattice.example.com;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name lattice.example.com;
ssl_certificate /etc/letsencrypt/live/lattice.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/lattice.example.com/privkey.pem;
client_max_body_size 50m;
location / {
proxy_pass http://127.0.0.1:8088;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}Then:
ln -sf /etc/nginx/sites-available/lattice.conf /etc/nginx/sites-enabled/lattice.conf
nginx -t
certbot --nginx -d lattice.example.com
systemctl reload nginxIf certbot --nginx also wrote lattice.example.com into
/etc/nginx/sites-enabled/default, NGINX will warn about a
conflicting server name and may ignore the Lattice proxy. Remove the duplicate
enabled site or remove the duplicate server block, then reload:
grep -R "server_name lattice.example.com" -n /etc/nginx/sites-enabled /etc/nginx/sites-available
unlink /etc/nginx/sites-enabled/default
nginx -t
systemctl reload nginxCloudflare settings for an orange-clouded record:
- DNS -> Records:
A lattice <origin-ip>, Proxy statusProxied. - SSL/TLS -> Overview:
Full (strict). - SSL/TLS -> Edge Certificates:
Always Use HTTPScan be enabled. - SSL/TLS -> Edge Certificates:
Automatic HTTPS Rewritescan be enabled. - Caching -> Cache Rules: bypass API caching with:
(http.host eq "lattice.example.com" and starts_with(http.request.uri.path, "/api/"))The cache rule action should be Bypass cache. Do not use Cache Everything
for the dashboard until authenticated and API paths are explicitly excluded.
Validate after DNS/proxy changes:
curl -fsS --resolve lattice.example.com:443:127.0.0.1 https://lattice.example.com/api/health
curl -fsS https://lattice.example.com/api/health
curl -I https://lattice.example.com/api/health | grep -iE 'cf-cache-status|server|location'