Skip to content

Dev server ports ​

A dev server is the one thing the shared router cannot hide. The page comes through the router on 443, but the browser fetches assets and opens the hot-reload socket on a port number of its own. So that number has to be the same in three places: what Docker publishes, what the dev server binds, and what the page tells the browser.

dqx takes care of the first two. The third lives in your project's config, and this page shows how to write it so it follows whatever port dqx hands out.

Where the port comes from ​

Every project owns a block of four ports, one per site, so several projects and several sites can run a dev server at the same time. dqx pins it as VITE_PORT and puts it in two places:

The container's environmentVITE_PORT is set for every process in the app container, not only for dqx dev. A watcher you start with dqx exec or dqx run sees it too.
The dqx dev command linedqx dev starts Vite with --host --port <port> --strictPort, and sets VITE_PORT to the same number.

dqx sync pins it in .dq/compose.env, starting from a port derived from the client key and taking the first block no other project on this machine overlaps. A port once pinned stays, so a dev server in use never moves, unless another project pinned the very same one: a copy of a project starts out with its original's. Then the project you sync moves on, and dqx sync says so.

dqx dev prints the port when it starts, and dqx exec printenv VITE_PORT shows it at any time. The name says Vite, but nothing about the variable is Vite's: any tool that reads its port from the environment can use it.

On the docker backend

This is about the docker backend, where the dev server runs in a container behind the router. On Herd or Lerd the dev server runs on your machine and binds its ports directly.

The four rules ​

Whatever the tool, a config that works behind dqx does the same four things:

  1. Read the port from process.env.VITE_PORT, with a fallback for when it is not set.
  2. Bind every address (0.0.0.0, or host: true in Vite). Loopback inside a container is not reachable from your machine.
  3. Refuse another port (strictPort or the tool's equivalent). A dev server that drifts to the next free port lands on one nothing publishes, and every asset is refused while its output looks fine.
  4. Tell the browser the site's domain and that same port. Never a port typed into the config: that is the line that breaks the moment the project runs on another number.

For https, dqx mounts the project's certificates at /etc/ssl/certs/local in the container. Serving the dev server with them keeps the asset URLs on https, like the page.

Vite with Laravel ​

js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import fs from 'node:fs';

const port = Number(process.env.VITE_PORT ?? 5173);
const host = 'app.acme.dqx';

const certs = '/etc/ssl/certs/local';
const https = fs.existsSync(`${certs}/local-domains.pem`) && {
    key: fs.readFileSync(`${certs}/local-domains-key.pem`),
    cert: fs.readFileSync(`${certs}/local-domains.pem`),
};

export default defineConfig({
    plugins: [laravel({ input: ['resources/js/app.js'], refresh: true })],
    server: {
        host: true,
        port,
        strictPort: true,
        https,
        allowedHosts: [host],
        hmr: { host },
        cors: true,
    },
});

There is no server.origin in it, on purpose. With hmr.host set, Laravel's plugin builds the URL it writes into the hot file from that host and the port Vite actually bound. If you do need an origin, build it from the same variable:

js
origin: `https://${host}:${port}`,

The same goes for hmr.port and hmr.clientPort: leave them out, or set them to port.

An older config that pins 5173

dqx dev runs Vite through an overlay when the config names a port of its own, so the project starts without anyone editing it first. dqx doctor, dqx sync and dqx adopt still report it, because the overlay only helps when Vite is started by dqx dev. See the daily commands.

Vite without Laravel ​

A Vue or React front end built by Vite and served by the site uses the same server block. Without Laravel's plugin there is no hot file: the page loads @vite/client from the URL your HTML names, so that URL uses the domain and port as well.

Laravel Mix and webpack-dev-server ​

dqx dev starts its asset server with Vite's flags, which webpack does not take. Start a webpack watcher with dqx run instead (see below); the container already has VITE_PORT.

With Laravel Mix, give the hot-reload server the port and the domain:

js
const mix = require('laravel-mix');

const port = Number(process.env.VITE_PORT ?? 8080);

mix.options({
    hmrOptions: { host: 'app.acme.dqx', port },
});

With webpack-dev-server itself (version 4 or newer):

js
const fs = require('node:fs');

const port = Number(process.env.VITE_PORT ?? 8080);
const certs = '/etc/ssl/certs/local';

module.exports = {
    devServer: {
        host: '0.0.0.0',
        port,
        allowedHosts: ['app.acme.dqx'],
        server: {
            type: 'https',
            options: {
                key: fs.readFileSync(`${certs}/local-domains-key.pem`),
                cert: fs.readFileSync(`${certs}/local-domains.pem`),
            },
        },
        client: { webSocketURL: `wss://app.acme.dqx:${port}/ws` },
    },
};

webpack-dev-server has no strict-port switch: it fails on a taken port only when port is a number, so always pass one rather than 'auto'.

Gulp with BrowserSync ​

js
const browserSync = require('browser-sync').create();
const fs = require('node:fs');

const port = Number(process.env.VITE_PORT ?? 3000);
const certs = '/etc/ssl/certs/local';

browserSync.init({
    port,
    ui: false,
    open: false,
    https: {
        key: `${certs}/local-domains-key.pem`,
        cert: `${certs}/local-domains.pem`,
    },
    socket: { domain: `app.acme.dqx:${port}` },
});

ui: false matters: BrowserSync otherwise opens a second port for its own control panel, and that one is not published.

Starting something that is not Vite ​

Declare it once in the project's .dq/config.yml and start it with dqx run:

yaml
commands:
  hot:
    name: Hot reload
    info: webpack-dev-server on this project's port
    script:
      - npm run hot
bash
dqx run hot

It runs in the app container, so VITE_PORT is already set and the config above picks it up.

A site that is a dev server ​

Nuxt, Next and other servers that render the page themselves are a different shape: nginx forwards the whole domain to them, so the browser never sees their port. Those sites declare proxy (the port) and proxyCommand (the command) in .dq/config.yml, and dqx dev starts them.

proxyCommand runs in a shell, so FOO=1 npm run dev and && work. dqx dev sets VITE_PORT to the proxy port for it, which wins over the container's own VITE_PORT (the asset port).

A Vite single-page app behind the proxy needs only proxy: 9090 when proxyCommand is the default npm run dev and the dev script is Vite itself. dqx dev then starts Vite with --host --port 9090 --strictPort and an overlay that, for this run only:

  • turns server.https off, since TLS ends at the router and nginx speaks plain HTTP to Vite;
  • sends the HMR websocket to wss://<domain>:443, through the router;
  • adds the site's domains to server.allowedHosts.

The project's own vite.config.* is not changed. --no-overlay starts Vite without it. A proxyCommand of your own is run as written: pass the port and the settings yourself.

While the dev server is down ​

On docker and DDEV, a dev-server site that has a build in its publicDir serves that build while nothing listens on its proxy port, instead of a 502. Deep links get the build's index.html, so the app's own router works. Once dqx dev runs, nginx forwards to the dev server again, without a reload. Without publicDir/index.html the site answers 502, as it did before.

yaml
- domains: {primary: mijn.acme.test}
  proxy: 9090
  publicDir: dist                 # where the build lands
  proxyBuild: npm run build-dev   # what `dqx build` runs

You build, not dqx. dqx build runs the site's proxyBuild in its container, and only when you or an agent asks (dqx_build, behind --allow-write, with this command and no other): --site for one site, standing in a checkout for that one, --here in a git worktree inside the checkout (that build lands in the worktree; nginx serves the checkout's). proxyBuild may be anything that writes publicDir: npm run build-dev, bun run build, gulp build. Building some other way works too: the fallback only looks for publicDir/index.html.

Choose a build that targets the local site. A build talks to the API its mode bakes in, and Vite loads .env.[mode] over .env.local. vite build without --mode is mode production, so a project with a .env.production that names its live API gets a local site that talks to production. vite build --mode dev without a .env.dev reads only .env and .env.local. Say in the project's README which build is the local one.

A served build carries the header X-Dqx-Served: build, and dqx says so wherever it matters: the dashboard shows dev, build or off (red) after a dev-server site's url while its project runs, dqx verify adds a line, and dqx e2e prints mijn.acme.test serves its build of 2026-10-15 16:06, not the dev server before it runs, since the journeys then test that build, not the code being edited. Lerd and herd have no place for the fallback: there a dev-server site without its dev server still answers 502.

dqx v1.78.0