-
-
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.
Why it matters here: the runner has to reach Fleet, and a container's address changes every time it restarts. A name doesn't. During the writing of this guide the Fleet container moved address three times and nothing broke, because everything referred to it by name.
See Networking and DNS for what this does and doesn't do.
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=fleetlab-fleet.test"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. Set the server URL to the name, not an address:
https://fleetlab-fleet.test:1337. Fleet records this and uses it for enrolment, and an
address here would break the first time anything restarts.
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 |
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. A container machine gets an address on the same network,
so running
fleetdin one to enrol it into this Fleet ought to work. It isn't tested here. -
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 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