Fleet is an open source server for osquery. It gives you a central config for every agent, live queries that return in seconds, a searchable host inventory, software inventory with CVE matching, and scheduled reports. The free tier under the MIT license covers all of that. Premium adds teams-style scoping, MDM extras, and SSO features you do not need for a Linux VPS fleet.
This guide deploys Fleet on a single RamNode VPS with Docker Compose, fronted by Caddy for TLS, and enrolls Linux hosts. Target version is Fleet 4.86 or later.
Terminology change you will trip over
Fleet 4.82.0 renamed teams to fleets and queries to reports across the UI, API, CLI, and GitOps. Older field names still work but emit deprecation warnings. Any tutorial or Stack Overflow answer written before March 2026 uses the old vocabulary. When you read team_id in one place and fleet_id in another, that is why.
Architecture
Fleet needs three moving parts:
| Component | Role | Notes |
|---|---|---|
| Fleet server | Go binary, stateless, serves UI and the osquery TLS API | Scale horizontally later if needed |
| MySQL | All persistent state | 8.0.x, 8.4.x, and 9.x are tested upstream. Do not use MariaDB |
| Redis | Live query pub/sub and caching | Redis 6 or later. Fleet is no longer verified against Redis 5 or below |
Agents talk to Fleet over HTTPS on 443. Fleet talks to MySQL and Redis on the loopback or a private network. Nothing else needs to be exposed.
Sizing on RamNode
| Fleet size | Suggested plan |
|---|---|
| Under 100 hosts | 2 vCPU, 4 GB RAM, 40 GB SSD |
| 100 to 1,000 hosts | 4 vCPU, 8 GB RAM, 80 GB SSD |
| Over 1,000 hosts | Split MySQL onto its own instance and add a second Fleet server behind a load balancer |
MySQL is the bottleneck long before the Go process is. Software inventory and vulnerability scanning are write-heavy, and the vulnerability cron is the single most expensive recurring job. Budget disk accordingly: the CVE feed and the software tables grow faster than most people expect.
Step 1: Prepare the host
sudo apt update && sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" \
| sudo tee /etc/apt/sources.list.d/docker.list
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-pluginPoint DNS at the VPS before you go further. Fleet bakes its server URL into every agent package, and changing it later means regenerating and redeploying every installer.
export FLEET_FQDN=fleet.example.com
dig +short $FLEET_FQDNOpen 80 and 443, and nothing else:
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enableStep 2: Lay out the stack
sudo mkdir -p /opt/fleet/{mysql,redis,caddy,logs,vulndb}
cd /opt/fleetGenerate secrets:
cat > .env <<EOF
FLEET_FQDN=fleet.example.com
MYSQL_ROOT_PASSWORD=$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)
MYSQL_PASSWORD=$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)
FLEET_SERVER_PRIVATE_KEY=$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)
EOF
chmod 600 .envFLEET_SERVER_PRIVATE_KEY encrypts secrets at rest in the database, including enroll secrets and any certificate material. Back it up somewhere other than this VPS. Lose it and the encrypted rows are unrecoverable.
Step 3: Write the compose file
# /opt/fleet/docker-compose.yml
services:
mysql:
image: mysql:8.4
restart: unless-stopped
command:
- --default-authentication-plugin=caching_sha2_password
- --innodb-buffer-pool-size=1G
- --innodb-log-file-size=256M
- --max-connections=300
- --collation-server=utf8mb4_unicode_ci
- --character-set-server=utf8mb4
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: fleet
MYSQL_USER: fleet
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- ./mysql:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 10
networks: [fleet]
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "no", "--save", "", "--maxmemory", "512mb", "--maxmemory-policy", "noeviction"]
volumes:
- ./redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks: [fleet]
fleet:
image: fleetdm/fleet:v4.86.0
restart: unless-stopped
depends_on:
mysql: {condition: service_healthy}
redis: {condition: service_healthy}
command: sh -c "fleet prepare db --no-prompt && fleet serve"
environment:
FLEET_MYSQL_ADDRESS: mysql:3306
FLEET_MYSQL_DATABASE: fleet
FLEET_MYSQL_USERNAME: fleet
FLEET_MYSQL_PASSWORD: ${MYSQL_PASSWORD}
FLEET_REDIS_ADDRESS: redis:6379
FLEET_SERVER_ADDRESS: 0.0.0.0:8080
FLEET_SERVER_TLS: "false"
FLEET_SERVER_URL: https://${FLEET_FQDN}
FLEET_SERVER_PRIVATE_KEY: ${FLEET_SERVER_PRIVATE_KEY}
FLEET_LOGGING_JSON: "true"
FLEET_OSQUERY_STATUS_LOG_PLUGIN: filesystem
FLEET_OSQUERY_RESULT_LOG_PLUGIN: filesystem
FLEET_FILESYSTEM_STATUS_LOG_FILE: /logs/osquery_status
FLEET_FILESYSTEM_RESULT_LOG_FILE: /logs/osquery_result
FLEET_VULNERABILITIES_DATABASES_PATH: /vulndb
volumes:
- ./logs:/logs
- ./vulndb:/vulndb
networks: [fleet]
caddy:
image: caddy:2-alpine
restart: unless-stopped
depends_on: [fleet]
ports:
- "80:80"
- "443:443"
environment:
FLEET_FQDN: ${FLEET_FQDN}
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy/data:/data
- ./caddy/config:/config
networks: [fleet]
networks:
fleet:
driver: bridgeTLS terminates at Caddy, so FLEET_SERVER_TLS is false and Fleet never sees a certificate. Do not expose 8080 to the internet.
# /opt/fleet/caddy/Caddyfile
{$FLEET_FQDN} {
encode gzip
reverse_proxy fleet:8080 {
header_up X-Forwarded-Proto https
}
request_body {
max_size 12GB
}
}The body size limit matters. Fleet's default software installer ceiling is 10 GiB, and a proxy that caps at 10 MB will reject uploads with a confusing error.
Fleet gzips responses when the client asks for it, but only if gzip_responses is enabled server side. Caddy compressing on the way out covers the UI regardless.
Step 4: Bring it up
cd /opt/fleet
docker compose up -d
docker compose logs -f fleetThe fleet prepare db step runs migrations before fleet serve starts. On a fresh database this takes seconds. On an upgrade across several minor versions it can take minutes, and the server refuses to serve until migrations complete. That is deliberate.
Watch for the line confirming the server is listening, then hit https://fleet.example.com. Caddy will pull a certificate from Let's Encrypt on first request. Create the admin account in the setup wizard.
Step 5: Install fleetctl
fleetctl drives everything from the command line and generates agent packages.
npm install -g fleetctl
# or grab the binary release for your platform
fleetctl config set --address https://fleet.example.com
fleetctl login
fleetctl get hostsStep 6: Enroll hosts
Fleet ships fleetd, a bundle of osquery plus Orbit (the updater and supervisor) plus Fleet Desktop. On a headless VPS you want the first two and not the third.
Get your enroll secret from the UI under Hosts, Add hosts, or:
fleetctl get enroll_secretBuild a package. For Debian and Ubuntu targets:
fleetctl package --type=deb \
--fleet-url=https://fleet.example.com \
--enroll-secret=YOUR_ENROLL_SECRET \
--update-interval=1hFor RHEL family:
fleetctl package --type=rpm \
--fleet-url=https://fleet.example.com \
--enroll-secret=YOUR_ENROLL_SECRETAdd --outfile=fleetd-prod to control the filename. Arch Linux targets use --type=pacman and produce a .pkg.tar.zst.
Copy the package to a target VPS and install:
sudo dpkg -i fleet-osquery_*.deb
sudo systemctl status orbitThe host appears in the UI within a minute or two.
If you already run plain osquery
You do not have to switch to fleetd. Point an existing osqueryd at Fleet's TLS endpoints instead:
--enroll_secret_path=/etc/osquery/enroll_secret
--tls_server_certs=/etc/osquery/fleet.pem
--tls_hostname=fleet.example.com
--host_identifier=uuid
--enroll_tls_endpoint=/api/osquery/enroll
--config_plugin=tls
--config_tls_endpoint=/api/osquery/config
--config_refresh=60
--disable_distributed=false
--distributed_plugin=tls
--distributed_interval=10
--distributed_tls_max_attempts=3
--distributed_tls_read_endpoint=/api/osquery/distributed/read
--distributed_tls_write_endpoint=/api/osquery/distributed/write
--logger_plugin=tls
--logger_tls_endpoint=/api/osquery/log
--logger_tls_period=10You lose auto-updating and Fleet's extra tables, and you keep full control over the agent. For a VPS fleet where you already manage osquery through configuration management, that is often the better trade.
Step 7: Do something useful with it
Live query. Ask every host a question and get answers in seconds:
fleetctl query --hosts all --query "SELECT name, version FROM deb_packages WHERE name LIKE 'openssl%';"Scheduled reports. Save a query, set an interval, and Fleet stores results per host. Results are capped at 1,000 rows per query, and the UI marks a report as clipped when it hits that ceiling.
Policies. A policy is a query that must return exactly one row to pass. This is where compliance lives:
-- Pass if unattended-upgrades is running
SELECT 1 FROM processes WHERE name = 'unattended-upgr';
-- Pass if SSH password auth is off
SELECT 1 FROM augeas
WHERE path = '/etc/ssh/sshd_config'
AND node = '/files/etc/ssh/sshd_config/PasswordAuthentication'
AND value = 'no';
-- Pass if no process is running from a deleted binary
SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM processes WHERE on_disk = 0);Vulnerability scanning. Enabled by default. Fleet cross-references software inventory against NVD, OVAL, MSRC, and OSV feeds and reports CVEs per host. On Linux it now handles kernel vulnerabilities on RHEL-family hosts and pulls OSV data for Ubuntu. The cron runs every hour by default and is the heaviest recurring job on the box. If MySQL is struggling, that is where to look first.
Step 8: Manage it as code
GitOps is the right answer past a handful of hosts. Scaffold a repo:
fleetctl newThat produces a folder with default.yml, unassigned.yml, and directories for reports, policies, and software. Apply it:
fleetctl gitops --dry-run -f default.yml
fleetctl gitops -f default.ymlThen wire it to CI. Notes from the field:
- GitOps fails the run on unknown keys. That is a feature. Typos do not silently no-op.
- Omitting
secrets:preserves the existing enroll secrets on the server rather than clearing them. - Omitting the
hosts:key under a manual label preserves existing membership rather than emptying it. no-team.ymlis deprecated in favor ofunassigned.yml.- Turn on GitOps mode in the UI once your repo is authoritative. It disables conflicting edits in the web interface.
Backups
Everything that matters is in MySQL plus the private key.
#!/usr/bin/env bash
set -euo pipefail
cd /opt/fleet
source .env
STAMP=$(date +%F-%H%M)
docker compose exec -T mysql mysqldump \
--single-transaction --routines --triggers \
-u root -p"${MYSQL_ROOT_PASSWORD}" fleet \
| gzip > "/var/backups/fleet-${STAMP}.sql.gz"
find /var/backups -name 'fleet-*.sql.gz' -mtime +14 -deleteUse --single-transaction so you do not lock the tables while agents are checking in. Store .env separately and encrypted. Redis holds only live query state and cache, so it does not need backing up.
Upgrades
Fleet ships a release roughly every three weeks. Do not skip more than a few minors at once.
cd /opt/fleet
docker compose exec -T mysql mysqldump --single-transaction -u root -p"$MYSQL_ROOT_PASSWORD" fleet | gzip > /var/backups/fleet-pre-upgrade.sql.gz
sed -i 's|fleetdm/fleet:v4.86.0|fleetdm/fleet:v4.87.0|' docker-compose.yml
docker compose pull fleet
docker compose up -d fleet
docker compose logs -f fleetRead the changelog first. Some releases carry migrations that take real time on large tables, and upstream flags those explicitly. Test the migration duration against a copy of your database if you have millions of rows in distributed_query_campaign_targets or host_software.
Troubleshooting
Hosts enroll then go offline. Check the agent side first:
sudo journalctl -u orbit -fAlmost always TLS. If Caddy is serving a cert for the wrong name, or the host cannot resolve the FQDN, enrollment succeeds against a cached config and then check-ins fail.
Fleet container restarts in a loop. Read the logs before assuming the worst:
docker compose logs fleet | tail -50Migration failures and a wrong FLEET_SERVER_PRIVATE_KEY both present as a crash loop. The private key must be identical to the one used when the data was written.
MySQL connection errors under load. Raise --max-connections and check FLEET_MYSQL_MAX_OPEN_CONNS. Default connection pooling is tuned for a bigger box than a 4 GB VPS.
Live queries return nothing. Redis is the pub/sub bus. If Redis is up but maxmemory-policy is set to an eviction policy, live query campaign keys can vanish mid-run. Keep it on noeviction.
Vulnerability data is stale or missing. The feed lives in FLEET_VULNERABILITIES_DATABASES_PATH. If the volume is not writable or the container cannot reach the CVE feed sources, the cron fails quietly. Also confirm outbound access to maintained-apps.fleetdm.com, which is where Fleet-maintained app manifests moved to.
Query marked as having invalid syntax when it is fine. Fleet validates SQL client side and has historically been wrong about CTEs, bitwise operators, table aliases, and ESCAPE in LIKE. You can save the query anyway.
Where to go next
Fleet answers "what is on my machines and is it compliant." It does not patch them. For patch management, channel mirroring, and configuration state on a mixed Linux fleet, see the companion guide on deploying Uyuni.
