Skip to content

Fleet GitOps Lab

Bart Reardon edited this page Aug 10, 2026 · 4 revisions

Building a 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.

What you'll build

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

What you need

  • ContainerManager 1.1.1 or later, and container 1.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.

Part 1 — Turn on local DNS

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.

Part 2 — Fleet

Get the compose file

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

Make three edits

1. 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: 4g

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

Fill in the .env

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

Import it

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.

Set Fleet up

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 namehttps://<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.

Part 3 — Gitea

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

We drop the GitHub and GitLab CI definitions and write a Gitea one below.

Part 4 — The runner

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.

Build a runner image

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 perl

Build. It takes about ten seconds and produces local/fleet-runner:latest.

Get a registration token

In Gitea: Site Administration ▸ Actions ▸ Runners ▸ Create new Runner. Copy the token.

Add the runner to the Gitea stack

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.

Part 5 — The GitOps loop

The workflow

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

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

The secrets

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.

Turn off the enroll-secret exception

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.

Push

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 main

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

Prove it's a loop

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.

What this lab doesn't cover

  • Enrolling hosts. Running fleetd in 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.

Troubleshooting

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.

Tearing it down

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.

Clone this wiki locally