osquery Manager
    Inventory & CVE

    Deploy Fleet on a VPS

    Self-host Fleet, the open source osquery manager, on a RamNode VPS with Docker Compose, MySQL, Redis, and Caddy TLS — central config, live queries, host inventory, and CVE matching.

    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:

    ComponentRoleNotes
    Fleet serverGo binary, stateless, serves UI and the osquery TLS APIScale horizontally later if needed
    MySQLAll persistent state8.0.x, 8.4.x, and 9.x are tested upstream. Do not use MariaDB
    RedisLive query pub/sub and cachingRedis 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 sizeSuggested plan
    Under 100 hosts2 vCPU, 4 GB RAM, 40 GB SSD
    100 to 1,000 hosts4 vCPU, 8 GB RAM, 80 GB SSD
    Over 1,000 hostsSplit 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

    shell
    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-plugin

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

    shell
    export FLEET_FQDN=fleet.example.com
    dig +short $FLEET_FQDN

    Open 80 and 443, and nothing else:

    shell
    sudo ufw allow 22/tcp
    sudo ufw allow 80/tcp
    sudo ufw allow 443/tcp
    sudo ufw enable

    Step 2: Lay out the stack

    shell
    sudo mkdir -p /opt/fleet/{mysql,redis,caddy,logs,vulndb}
    cd /opt/fleet

    Generate secrets:

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

    FLEET_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

    shell
    # /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: bridge

    TLS terminates at Caddy, so FLEET_SERVER_TLS is false and Fleet never sees a certificate. Do not expose 8080 to the internet.

    shell
    # /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

    shell
    cd /opt/fleet
    docker compose up -d
    docker compose logs -f fleet

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

    shell
    npm install -g fleetctl
    # or grab the binary release for your platform
    fleetctl config set --address https://fleet.example.com
    fleetctl login
    fleetctl get hosts

    Step 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:

    shell
    fleetctl get enroll_secret

    Build a package. For Debian and Ubuntu targets:

    shell
    fleetctl package --type=deb \
      --fleet-url=https://fleet.example.com \
      --enroll-secret=YOUR_ENROLL_SECRET \
      --update-interval=1h

    For RHEL family:

    shell
    fleetctl package --type=rpm \
      --fleet-url=https://fleet.example.com \
      --enroll-secret=YOUR_ENROLL_SECRET

    Add --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:

    shell
    sudo dpkg -i fleet-osquery_*.deb
    sudo systemctl status orbit

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

    shell
    --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=10

    You 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:

    shell
    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:

    shell
    -- 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:

    shell
    fleetctl new

    That produces a folder with default.yml, unassigned.yml, and directories for reports, policies, and software. Apply it:

    shell
    fleetctl gitops --dry-run -f default.yml
    fleetctl gitops -f default.yml

    Then 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.yml is deprecated in favor of unassigned.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.

    shell
    #!/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 -delete

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

    shell
    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 fleet

    Read 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:

    shell
    sudo journalctl -u orbit -f

    Almost 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:

    shell
    docker compose logs fleet | tail -50

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