-
-
Notifications
You must be signed in to change notification settings - Fork 0
Fleet GitOps Lab
An end-to-end walkthrough: a Fleet server, a Gitea Git server with CI, and a runner that applies configuration to Fleet every time you push — all in Apple containers on one Mac, with no Docker anywhere.
By the end, editing a YAML file and pushing it changes your Fleet instance.
It's the longest guide here, and it's deliberately honest about the parts that bite. Every error message quoted below is one this lab actually produces if you skip the step above it.
| Piece | What it is |
|---|---|
fleetlab stack |
Fleet, MySQL, Redis and a one-shot init container, from Fleet's own compose file |
gitealab stack |
Gitea, plus a CI runner you build yourself |
| A GitOps repo | Fleet's gitops-workshop, hosted in your Gitea |
- ContainerManager 1.1.1 or later, and
container1.2.x. - About 6 GB of disk for images, and patience for the first pull.
- A Fleet Premium trial licence for the GitOps part. Fleet's free tier runs fine, but the workshop repo defines fleets (teams), which are Premium. There's a 30-day trial key dispenser; you can stop at the end of Part 2 without one.
A note on time. Fleet publishes no arm64 image, so it runs under emulation. It's perfectly usable, but the first start takes minutes rather than seconds.
Do this first. Containers get names only when they're created, so setting it up later means re-creating everything.
Settings ▸ Local DNS, type a domain (test is conventional), Set Up…. It asks for
your administrator password — macOS needs a resolver entry — writes the matching service
setting, and restarts the services.
What this buys you here: https://fleetlab-fleet.test:1337 in your browser instead of an
address that changes on every restart, and names between the services inside a stack.
What it does not buy you is a route between stacks. Fleet and Gitea end up on separate networks, and a name that resolves is not the same as a host you can reach — see the secrets, where the runner uses your Mac's own name instead.
See Networking and DNS for the full picture.
mkdir ~/fleet-lab && cd ~/fleet-lab
curl -O https://raw.githubusercontent.com/fleetdm/fleet/refs/heads/main/docs/solutions/docker-compose/docker-compose.yml
curl -O https://raw.githubusercontent.com/fleetdm/fleet/refs/heads/main/docs/solutions/docker-compose/env.example
cp env.example .env1. Generate the certificates. Not optional, even if you turn TLS off — the compose file mounts them unconditionally, and ContainerManager refuses a stack whose host paths don't exist. That check is the first thing you'd hit otherwise.
mkdir certs
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout certs/fleet.key -out certs/fleet.crt -subj "/CN=$(hostname)"It's self-signed either way, so your browser will warn and the runner skips verification — the name in it doesn't have to be right. Using your Mac's own name simply keeps it closest to the address you'll settle on.
2. Give Fleet enough memory. Add one line to the fleet: service:
fleet:
image: fleetdm/fleet
platform: linux/x86_64
mem_limit: 4gSkip this and the lab works — for about an hour. Then Fleet's vulnerability cron fires, and a 1 GB emulated container stops answering: the port stays open, nothing is accepted, and nothing is logged. There is no error to find, which is why it's worth doing now.
3. Let MySQL run natively. The compose file pins it to x86, but mysql:8 has a
perfectly good arm64 image. Delete this line from the mysql: service and it runs at full
speed:
platform: linux/x86_64 # <- delete this one, under mysql:Leave the identical line under fleet: alone — Fleet genuinely has no arm64 image.
Edit .env and set at least:
MYSQL_ROOT_PASSWORD=<something>
MYSQL_PASSWORD=<something>
FLEET_SERVER_PRIVATE_KEY=<openssl rand -base64 32>
FLEET_LICENSE_KEY=<your trial key, or leave empty for the free tier>
Leave FLEET_SERVER_TLS=true. Fleet must serve TLS, because fleetctl refuses a
plain-http remote address later:
Error: error creating Fleet API client handler: address must start with https:// for remote connections
Stacks ▸ + ▸ Import Template… and choose your docker-compose.yml.
An alert lists what didn't carry over. That's the importer being honest, not a failure —
restart:, healthcheck: and cap_add: have no equivalent, and none of them change what
this stack does. The same list stays in the stack's Log.
The create form is prefilled from your .env, with the passwords masked. Name the stack
fleetlab and Create.
What it builds: a fleetlab-net network, five volumes, and four services in dependency
order — fleet-init fixes volume ownership and exits, MySQL and Redis come up, then Fleet.
The FLEET_MYSQL_ADDRESS=mysql:3306 in the compose file has already been rewritten to
${IP:mysql}:3306, so the wiring is done for you.
First start takes a few minutes: Fleet runs its database migrations under emulation.
The stack's detail pane shows the address. Open it — https://fleetlab-fleet.test:1337 —
and accept the certificate warning; it's the self-signed certificate you generated.
Create the admin account. For the server URL, use your Mac's own DNS name —
https://<mac-hostname.domain>:1337 — the same value the runner gets later.
The container name https://fleetlab-fleet.test:1337 works from your browser and is fine
for looking around, but it isn't reachable from the runner, and the workshop's
default.yml sets server_url from FLEET_URL on the first run. Starting with the value
GitOps is going to set anyway saves it changing under you.
Confirm you're on Premium under Settings ▸ Organization settings if you used a licence key.
Free tier? Stop here. You have a working Fleet. The rest needs Premium, because the workshop repo defines fleets.
Stacks ▸ + ▸ Gitea, name it gitealab, Create. It's arm64 and starts in seconds.
Open it from the stack's detail pane and complete the first-run form. Gitea Actions is on by default in current versions; check Site Administration ▸ Actions if in doubt.
Create a repository called fleet-gitops, and push the workshop into it:
cd ~/fleet-lab
git clone --depth 1 https://github.com/fleetdm/gitops-workshop.git gitops
cd gitops && rm -rf .git .github/workflows .gitlab-ci.ymlWe drop the GitHub and GitLab CI definitions and write a Gitea one below.
This is the part that looks impossible and isn't. A CI runner normally needs a Docker socket to run jobs in. There isn't one here — but a Gitea runner label defaults to host execution, meaning jobs run directly inside the runner container. No Docker required.
The stock runner image is Alpine with git and bash, and the workshop's gitops.sh
needs rather more than that.
Images ▸ Build Image…, name it fleet-runner, and use:
FROM docker.io/gitea/runner:latest
USER root
# Host-mode jobs run inside this container, so everything the workflow needs lives here:
# npm installs fleetctl, and gitops.sh needs bash, curl, jq and perl.
RUN apk add --no-cache nodejs npm curl jq perlBuild. It takes about ten seconds and produces local/fleet-runner:latest.
In Gitea: Site Administration ▸ Actions ▸ Runners ▸ Create new Runner. Copy the token.
Containers ▸ +:
| Field | Value |
|---|---|
| Name | gitealab-runner |
| Image | local/fleet-runner:latest |
| Stack | gitealab |
Choosing the stack joins it to the stack's network and means it starts and stops with Gitea. Then set the environment:
GITEA_INSTANCE_URL=http://gitealab-gitea.test:3000
GITEA_RUNNER_REGISTRATION_TOKEN=<the token you copied>
GITEA_RUNNER_NAME=fleetlab-runner
GITEA_RUNNER_LABELS=fleet-lab
fleet-lab with no :docker:// suffix is the important bit. Check the container's log
after it starts:
level=info msg="labels updated to: [fleet-lab:host]"
level=info msg="runner: fleetlab-runner, ... declare successfully"
host is what you want. If it says docker, the label has a suffix it shouldn't.
Create .gitea/workflows/fleet-gitops.yml in your clone:
name: Apply configuration to Fleet
on:
push:
branches: [main]
workflow_dispatch:
jobs:
fleet-gitops:
runs-on: fleet-lab # bare label = host executor, no Docker socket
steps:
- uses: actions/checkout@v4
- name: Install fleetctl
run: npm install -g fleetctl@4.90.0
- name: Apply
env:
FLEET_URL: ${{ secrets.FLEET_URL }}
FLEET_API_TOKEN: ${{ secrets.FLEET_API_TOKEN }}
FLEET_GLOBAL_ENROLL_SECRET: ${{ secrets.FLEET_GLOBAL_ENROLL_SECRET }}
FLEET_WORKSTATIONS_ENROLL_SECRET: ${{ secrets.FLEET_WORKSTATIONS_ENROLL_SECRET }}
FLEET_PERSONAL_MOBILE_DEVICES_ENROLL_SECRET: ${{ secrets.FLEET_PERSONAL_MOBILE_DEVICES_ENROLL_SECRET }}
run: |
# --tls-skip-verify: the lab's certificate is self-signed.
fleetctl config set --address "$FLEET_URL" --token "$FLEET_API_TOKEN" --tls-skip-verify
./gitops.shThe workshop ships a composite GitHub action that does the same thing; calling gitops.sh
directly keeps this readable and avoids depending on local-action support.
Get an API token from Fleet: Settings ▸ My account ▸ Get API token.
In your Gitea repo, Settings ▸ Actions ▸ Secrets, add:
| Secret | Value |
|---|---|
FLEET_URL |
https://<mac-hostname.domain>:1337 — or your Mac's LAN address, see below
|
FLEET_API_TOKEN |
the token |
FLEET_GLOBAL_ENROLL_SECRET |
any string |
FLEET_WORKSTATIONS_ENROLL_SECRET |
any string |
FLEET_PERSONAL_MOBILE_DEVICES_ENROLL_SECRET |
any string |
FLEET_URL uses the host machine DNS name deliberately. The runner and Fleet are in different stacks,
so they're on different, isolated container networks — the runner can't reach Fleet's
container directly across them. It still works
because the local DNS resolves the name and
the request reaches Fleet through the host, not by direct container-to-container
networking.
Fleet excepts enroll secrets from GitOps management by default, and the workshop's
default.yml has a secrets: key. Leave it and the first real run fails with:
Error: "secrets" is excepted from GitOps management. Remove the "secrets:" key from your
GitOps file or disable the exception in Fleet settings
In Fleet: Settings ▸ Integrations ▸ Change management, and turn the enroll-secrets exception off.
git init && git add -A && git commit -m "Fleet GitOps config"
git branch -M main
git remote add origin http://<user>:<password>@localhost:3000/<user>/fleet-gitops.git
git push -u origin mainWatch the run in Gitea under Actions. A successful one ends:
[+] applied fleet config
[+] applied enroll secrets
[+] applied 1 fleet
[+] applied 1 fleet
And in Fleet you'll now have two fleets — 💻 Workstations and
📱🔐 Personal mobile devices — that you never created by hand.
Change org_name in default.yml, commit, push. Watch the run, then reload Fleet: the
name has changed. That's the whole point of the exercise — the Git repository is now the
source of truth.
Finish by turning on GitOps mode (Fleet Settings ▸ Integrations ▸ Change management), which makes the UI read-only for anything GitOps manages, so the two can't drift.
-
Enrolling hosts. Running
fleetdin a container machine to enrol it ought to work, but it isn't tested here — and note the same trap as above: a machine on a different network than Fleet will resolve the name and fail to reach it, so point it at your Mac's name too. -
Anything production-shaped. Self-signed certificates,
--tls-skip-verify, secrets in a local Git server, vulnerability scanning off. It's a lab.
Fleet worked, then stopped after about an hour. The vulnerability cron on too little
memory. Set mem_limit: 4g, or add FLEET_VULNERABILITIES_CURRENT_INSTANCE_CHECKS=no to
the environment. Symptom is distinctive: the port accepts nothing and the log says nothing.
address must start with https://. Fleet is serving plain http. Set
FLEET_SERVER_TLS=true and make sure the certificates exist.
"secrets" is excepted from GitOps management. See
above.
The runner can't reach Fleet — the job fails on fleetctl with a connection error,
though the name resolves. Fleet and Gitea are separate stacks, so they're on separate
networks: names resolve globally but routing doesn't cross. Use your Mac's own DNS name in
FLEET_URL, not the container's.
Your Mac needs a real, resolvable DNS name for that — a Bonjour .local name won't
do, because containers don't answer multicast DNS. Two alternatives if you haven't got one,
both verified:
-
Your Mac's LAN address, e.g.
https://192.168.0.180:1337. A container on any network can reach the host on a published port. Simplest, and it works everywhere; the cost is that it changes if your Mac gets a new lease. - Put Fleet and Gitea in one stack. One network, so the container name works and none of this applies.
The job never starts. The runner's label and the workflow's runs-on must match
exactly. Check Site Administration ▸ Actions ▸ Runners shows it as idle, not offline.
fleetctl: not found or perl: not found. The runner is using the stock image rather
than the one you built.
The import refused a host path. The certificates aren't where the compose file says.
Run the openssl step.
Everything is slow. Fleet is emulated amd64. Check you removed the platform pin from
mysql: — that one has no reason to be emulated.
Delete both stacks from the Stacks section. Volumes are kept deliberately, so remove
mysql, redis, data, logs, vulndb and gitea-data from Volumes when you want
the data gone.
Those volume names come from the compose file as written, so they're generic and live in a global namespace. Worth renaming them in the compose file before importing if you plan to run other stacks alongside.
Getting started
Guides
- Stacks
- Stack definitions
- Importing docker-compose
- Machines
- Images and builds
- Networking and DNS
- Settings and updates
Help