Skip to content

Conversation

@lxfrdl
Copy link
Contributor

@lxfrdl lxfrdl commented Nov 21, 2025

I have added the EXPOSE 8080 instruction to the application Dockerfile.

While working with Traefik locally, the service was not accessible because Traefik could not detect the correct port. By default, Traefik looks for the EXPOSE instruction to determine which port to route traffic to.

This change ensures:

  1. Seamless integration with reverse proxies like Traefik (zero-config).
  2. Better documentation of the container's intended listening port (see Docker Docs).

@lxfrdl lxfrdl changed the base branch from master to dev November 21, 2025 23:39
@Koenkk Koenkk merged commit 4c21b66 into Koenkk:dev Nov 22, 2025
11 checks passed
@Koenkk
Copy link
Owner

Koenkk commented Nov 22, 2025

Thanks!

@lxfrdl lxfrdl deleted the add_expose_to_dockerfile branch November 22, 2025 12:32
@Nerivec
Copy link
Collaborator

Nerivec commented Nov 22, 2025

This will only work if the port config is kept default though? Might be worth a line somewhere in the docs (not sure if it's limited to Traefik or not).

@lxfrdl
Copy link
Contributor Author

lxfrdl commented Nov 22, 2025

This will only work if the port config is kept default though? Might be worth a line somewhere in the docs (not sure if it's limited to Traefik or not).

What exactly do you mean?
Given the following example

services:
  zigbee2mqtt:
    image: koenkk/zigbee2mqtt
    ports:
      - 8082:8080
    labels:
      - "traefik.enable=true"

You can access the frontend via :8082 (host port)
Inside the docker network traefik cann still access zigbee2mqtt on exposed port 8080 (container port).

@Nerivec
Copy link
Collaborator

Nerivec commented Nov 22, 2025

configuration.yaml

frontend:
    enabled: true
    port: 8181

Z2M frontend/websocket will be on 8181.

override async start(): Promise<void> {
if (settings.get().frontend.disable_ui_serving) {
const {host, port} = settings.get().frontend;
this.wss = new WebSocket.Server({port, host, path: posix.join(this.baseUrl, "api")});
logger.info(
/* v8 ignore next */
`Frontend UI serving is disabled. WebSocket at: ${this.wss.options.host ?? "0.0.0.0"}:${this.wss.options.port}${this.wss.options.path}`,
);
} else {
const {host, port, ssl_key: sslKey, ssl_cert: sslCert} = settings.get().frontend;
const hasSSL = (val: string | undefined, key: string): val is string => {
if (val) {
if (existsSync(val)) {
return true;
}
logger.error(`Defined ${key} '${val}' file path does not exists, server won't be secured.`);
}
return false;
};
const options: expressStaticGzip.ExpressStaticGzipOptions = {
enableBrotli: true,
serveStatic: {
/* v8 ignore start */
setHeaders: (res: ServerResponse, path: string): void => {
if (path.endsWith("index.html")) {
res.setHeader("Cache-Control", "no-store");
}
},
/* v8 ignore stop */
},
};
const frontend = (await import(settings.get().frontend.package)) as typeof import("zigbee2mqtt-frontend");
const fileServer = expressStaticGzip(frontend.default.getPath(), options);
const deviceIconsFileServer = expressStaticGzip(data.joinPath("device_icons"), options);
const onRequest = (request: IncomingMessage, response: ServerResponse): void => {
const next = finalhandler(request, response);
// biome-ignore lint/style/noNonNullAssertion: `Only valid for request obtained from Server`
const newUrl = posix.relative(this.baseUrl, request.url!);
// The request url is not within the frontend base url, so the relative path starts with '..'
if (newUrl.startsWith(".")) {
next();
return;
}
// Attach originalUrl so that static-server can perform a redirect to '/' when serving the root directory.
// This is necessary for the browser to resolve relative assets paths correctly.
request.originalUrl = request.url;
request.url = `/${newUrl}`;
request.path = request.url;
if (newUrl.startsWith("device_icons/")) {
request.path = request.path.replace("device_icons/", "");
request.url = request.url.replace("/device_icons", "");
deviceIconsFileServer(request, response, next);
} else {
fileServer(request, response, next);
}
};
if (hasSSL(sslKey, "ssl_key") && hasSSL(sslCert, "ssl_cert")) {
const serverOptions = {key: readFileSync(sslKey), cert: readFileSync(sslCert)};
this.server = createSecureServer(serverOptions, onRequest);
} else {
this.server = createServer(onRequest);
}
this.server.on("upgrade", this.onUpgrade);
if (!host) {
this.server.listen(port);
logger.info(`Started frontend on port ${port}`);
} else if (host.startsWith("/")) {
this.server.listen(host);
logger.info(`Started frontend on socket ${host}`);
} else {
this.server.listen(port, host);
logger.info(`Started frontend on port ${host}:${port}`);
}
this.wss = new WebSocket.Server({noServer: true, path: posix.join(this.baseUrl, "api")});
}
this.wss.on("connection", this.onWebSocketConnection);
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessageOrEntityState);
this.eventBus.onPublishEntityState(this, this.onMQTTPublishMessageOrEntityState);
}

@lxfrdl
Copy link
Contributor Author

lxfrdl commented Nov 22, 2025

Ah, thank you for the clarification! I wasn't aware the port could also be changed via the configuration.yaml file.

The problem is that the EXPOSE instruction is processed at build time and can not be changed later at runtime in the final image. However, this isn't a major issue, as the instruction mainly serves two purposes:

  1. Documentation: It informs users which ports are intended to be published.
  2. Automation: The docker run -P flag uses it to map the exposed ports automatically. Service discovery tools like Traefik can also use it as a hint.

For anyone who needs to use a different port, the recommended approach is to explicitly configure the reverse proxy. For example, with Traefik, you can use labels in your docker run command to specify the correct port. This overrides the hint from EXPOSE.

Here is an example:

docker run \
  -l "traefik.enable=true" \
  -l "traefik.http.services.zigbee2mqtt.loadbalancer.server.port=9090" \
  koenkk/zigbee2mqtt

This ensures Traefik directs traffic to the correct port (9090 in this case), regardless of what is set in the Dockerfile's EXPOSE instruction.

@Koenkk
Copy link
Owner

Koenkk commented Nov 22, 2025

This will only work if the port config is kept default though?

True (it will also be not correct when the frontend is completely disabled). But I think it's fine to have the default port listed here because in by far most of the setups I expect this will be the correct port.

@Nerivec
Copy link
Collaborator

Nerivec commented Nov 23, 2025

I was thinking maybe a little line in the docs for this kind of use should be added (Frontend config page?). Something along the lines of what was said here

@Koenkk
Copy link
Owner

Koenkk commented Nov 23, 2025

@Nerivec good point.

@lxfrdl could you submit a pr?

@lxfrdl
Copy link
Contributor Author

lxfrdl commented Nov 23, 2025

done Koenkk/zigbee2mqtt.io#4466

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants