To test MQTT in GitHub Actions, start a broker inside the job, wait until it accepts connections, and run your test suite against it — then add a second job that runs the same tests against a hosted broker with credentials stored as repository secrets. The first job catches logic bugs on every commit; the second catches TLS, authentication and ACL problems before your devices do.
This guide gives you copy-paste workflows for both and explains the one Mosquitto default that breaks most first attempts.
The "local only mode" trap
The first instinct is a service container:
services:
mosquitto:
image: eclipse-mosquitto:2
ports:
- 1883:1883The container starts, the port is mapped, and every test fails with Connection refused or ECONNRESET. The broker log explains why:
mosquitto version 2.0.x starting
Config loaded from /mosquitto/config/mosquitto.conf.
Starting in local only mode. Connections will only be possible from clients running on this machine.
Create a configuration file which defines a listener to allow remote access.Since version 2.0, Mosquitto with no listener in its configuration binds only to the loopback interface inside its own container. Traffic arriving through the port mapping comes from outside, so it is refused. The image's default configuration file has every option commented out, so this is exactly what you get.
Service containers make the fix awkward: they start before any step runs, so your repository (and its mosquitto.conf) is not checked out yet. The simplest, most predictable approach is to start the broker yourself in a step. The same issue is covered for local setups in Mosquitto in Docker and Mosquitto config not loading.
Job 1: Mosquitto in the workflow
Commit a minimal test configuration to the repository:
listener 1883 0.0.0.0
allow_anonymous true
persistence false
log_dest stdoutYou can generate stricter variants — with a password file or ACLs to test denials — with the Mosquitto config generator. Then start the broker after checkout and wait until it really answers MQTT, not just TCP:
name: mqtt-tests
on:
push:
pull_request:
jobs:
local-broker:
runs-on: ubuntu-latest
env:
MQTT_HOST: localhost
MQTT_PORT: "1883"
steps:
- uses: actions/checkout@v4
- name: Start Mosquitto
run: |
docker run -d --name mosquitto -p 1883:1883 \
-v "$PWD/test/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro" \
eclipse-mosquitto:2
for i in $(seq 1 30); do
if docker exec mosquitto mosquitto_pub -h localhost -t testmqtt/ci/ready -m ok; then
echo "broker ready"; exit 0
fi
sleep 1
done
echo "broker did not start"; docker logs mosquitto; exit 1
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements-dev.txt
- run: pytest -v tests/
- name: Broker logs
if: failure()
run: docker logs mosquittoWhy the loop runs mosquitto_pub instead of checking the port: Docker's port proxy accepts TCP connections on the host as soon as the container exists, even before the broker inside is listening. A successful publish proves the broker is up. The eclipse-mosquitto image ships the command-line clients, so nothing extra has to be installed.
For a Node.js project, replace the Python steps with actions/setup-node@v4, npm ci and npm test; the broker steps stay the same.
Job 2: tests against a hosted broker
A container in CI is anonymous, plaintext and freshly configured on every run — the opposite of production. A second job runs the same suite over TLS with real credentials. Store them under Settings → Secrets and variables → Actions as MQTT_USER and MQTT_PASS:
hosted-broker:
runs-on: ubuntu-latest
needs: local-broker
# secrets are not available to pull requests from forks
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
concurrency:
group: mqtt-hosted-${{ github.ref }}
cancel-in-progress: true
env:
MQTT_HOST: broker.connectmq.com
MQTT_PORT: "8883"
MQTT_USER: ${{ secrets.MQTT_USER }}
MQTT_PASS: ${{ secrets.MQTT_PASS }}
MQTT_TOPIC_PREFIX: testmqtt/ci/${{ github.run_id }}-${{ github.run_attempt }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements-dev.txt
- run: pytest -v tests/What each part does:
needs: local-brokerruns the slower, networked job only when the fast job passed.- The
if:condition skips the job for forked pull requests, which do not receive secrets. Without it the job fails with an authentication error that looks like a real bug. MQTT_TOPIC_PREFIXincludes the run ID and attempt, so re-runs and parallel workflows never share topics. Your tests must build every topic from it — see MQTT integration testing for the fixture pattern.- GitHub masks secret values in logs automatically, but avoid printing connection options in debug output anyway.
Keeping the hosted job fast and tidy
A few habits keep the networked job reliable as the suite grows:
- Run a subset. Mark tests that need TLS, credentials or ACLs (for example
@pytest.mark.hostedor a separate Jest project) and run only those in the hosted job. Pure logic is already covered by the local job. - Clean up retained messages. Every retained message a test creates outlives the run. Clear it in teardown with an empty retained publish, or avoid retain flags outside tests that check retained behaviour.
- Use clean sessions. Persistent sessions with random client IDs accumulate on the broker until they expire. Use clean sessions (or a short MQTT 5 session expiry) unless a test is about sessions.
- Set timeouts for the network. A message that arrives in 20 ms locally may need a second from a shared runner across the internet. Use 5–10 second message timeouts in this job; the tests still finish as soon as the message arrives.
Testing against several brokers with a matrix
If your library or gateway has to work with more than one broker, a matrix runs the suite against each without duplicating YAML:
compat:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
image: ["eclipse-mosquitto:2", "emqx/emqx:latest", "hivemq/hivemq-ce:latest"]
steps:
- uses: actions/checkout@v4
- name: Start ${{ matrix.image }}
run: docker run -d --name broker -p 1883:1883 ${{ matrix.image }} ${{ startsWith(matrix.image, 'eclipse-mosquitto') && 'mosquitto -c /mosquitto-no-auth.conf' || '' }}
- name: Wait for MQTT
run: |
pip install paho-mqtt
python scripts/wait_for_broker.py localhost 1883 --timeout 60
- run: pip install -r requirements-dev.txt && pytest -v tests/EMQX and HiveMQ CE take longer to start than Mosquitto and their images do not include mosquitto_pub, so the readiness check runs from the runner. Pin exact image tags in real pipelines so a new broker release does not break your build unannounced. A small wait_for_broker.py only needs to loop over client.connect() until it succeeds or the timeout expires.
Debugging failing CI runs
| Error in the job log | Cause | Fix |
|---|---|---|
Connection refused / ECONNREFUSED right after start | Broker not ready yet, or local only mode | Use the MQTT readiness loop; declare a listener |
ECONNRESET on every connect | Port mapped but Mosquitto bound to loopback | listener 1883 0.0.0.0 in the config |
Connection Refused: not authorised in hosted job | Secret missing (fork PR) or wrong user | Check the if: guard and secret names |
[SSL: CERTIFICATE_VERIFY_FAILED] | TLS port used without CA, or wrong hostname | Use the system CA bundle and the broker's real hostname |
| Tests pass alone, fail in the matrix | Shared topics across jobs | Topic prefix with run ID and matrix value |
The if: failure() step that prints docker logs is worth keeping permanently: the broker log shows rejected connections, ACL denials and client IDs. For reason codes in client errors, use the MQTT reason code lookup.
Verifying the pipeline
Make the pipeline prove it can fail. Push a branch that publishes to the wrong topic and confirm the job turns red. Then, while the hosted job runs, subscribe to testmqtt/ci/# in the online MQTT client with the same credentials: you will see the run-specific prefix appear and the test traffic flow, which confirms the secrets, TLS and ACLs work as intended.
A reliable MQTT pipeline needs two things: a broker in the job that is configured to accept connections and checked with a real publish, and a second stage that exercises the TLS, credentials and ACLs you run in production. With unique topic prefixes and secrets kept out of fork builds, both run on every push without stepping on each other. If you would rather manage containers from the test code itself, see MQTT tests with Testcontainers.
Frequently asked questions
Why can't my GitHub Actions job connect to the Mosquitto container?
Mosquitto 2.x starts in local only mode when no listener is configured, so it only accepts connections from inside its own container. Start it with a configuration that declares listener 1883 and allow_anonymous true (or real credentials), for example the bundled /mosquitto-no-auth.conf.
Are GitHub secrets available to pull requests from forks?
No. Workflows triggered by pull requests from forked repositories do not receive repository secrets. Run hosted-broker tests only on pushes and on pull requests from the same repository, and keep the local-broker job for everything else.
Should CI use a local Mosquitto or a hosted broker?
Use both. A local container gives fast, isolated feedback on every commit. A job against a hosted broker verifies TLS, authentication and ACLs as they are configured in production, which a throwaway container cannot.



