DEV Community

Cover image for Why Traccar's WebSocket needs a proxy, and how to set one up with nginx
Telematics Lab
Telematics Lab

Posted on AI-assisted

Why Traccar's WebSocket needs a proxy, and how to set one up with nginx

You have Traccar running, the REST API answers, and now you want a custom web map that moves vehicles live. You open a WebSocket to /api/socket, and it fails. Or it connects from Traccar's own UI but never from your page.

This isn't a bug in your code. It comes from two design choices in Traccar, and once you see them, the fix is a short nginx config.

The two problems

1. The WebSocket only accepts a session cookie.

Traccar's REST API supports several ways to authenticate: a session cookie, a standard HTTP Authorization header, a bearer token. The WebSocket doesn't. Traccar's API documentation says it plainly: "Session cookie is the only authorization option for the WebSocket connection."

In the browser you can't add headers to a WebSocket anyway: the WebSocket constructor takes a URL and an optional list of subprotocols, nothing else. So the only way to authenticate is to have the browser send Traccar's JSESSIONID cookie with the handshake, and it only does that automatically for a request to the same site that set the cookie.

2. Traccar sends no CORS headers by default.

If your map lives on http://localhost:3000 and Traccar on http://localhost:8082, those are two different origins. Your fetch('/api/session') to log in becomes a cross-origin request, the browser blocks the response (no Access-Control-Allow-Origin), and even if you opened CORS up, you'd then be fighting the browser's rules for cookies on cross-origin requests.

You can try to solve both on the Traccar side. Or you can make them not exist.

The fix: one origin

Put a reverse proxy in front of everything. The proxy serves your frontend files and forwards /api/... to Traccar. From the browser's point of view there is only one server:

  browser  ── http://localhost:8080 ──►  nginx
                                          │  /            → your HTML/JS (static files)
                                          │  /api/*       → Traccar REST API
                                          │  /api/socket  → Traccar WebSocket (Upgrade forwarded)
                                          ▼
                                       traccar:8082
Enter fullscreen mode Exit fullscreen mode

Now POST /api/session sets the cookie on localhost:8080, the WebSocket to ws://localhost:8080/api/socket goes to the same origin, the browser attaches the cookie by itself, and CORS never comes up. No change to Traccar's configuration.

The nginx config

Here is the complete default.conf, unchanged. It was tested with traccar/traccar:6.15.3-alpine and nginx:1.27-alpine in Docker Compose (Docker Desktop on Windows, Chrome).

# nginx serves the frontend AND proxies the Traccar API on the same origin.
#
# Why: Traccar's WebSocket (/api/socket) only accepts a session cookie, and
# Traccar does not send CORS headers by default. If the page and the API share
# one origin (http://localhost:8080), the browser sends the cookie
# automatically and CORS never comes up. No Traccar config changes needed.

server {
    listen 80;
    server_name _;

    root  /usr/share/nginx/html;
    index index.html;

    # Live updates: WebSocket upgrade must be forwarded explicitly.
    location = /api/socket {
        proxy_pass http://traccar:8082;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_read_timeout 1h;             # keep idle sockets open
    }

    # Everything else under /api -> Traccar REST API.
    location /api/ {
        proxy_pass http://traccar:8082;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    # Always serve fresh files while you edit the frontend.
    location / {
        add_header Cache-Control "no-store";
        try_files $uri $uri/ =404;
    }
}
Enter fullscreen mode Exit fullscreen mode

What each block does:

  • location = /api/socket: the = makes this an exact match, and nginx always checks exact matches before prefix matches like /api/. So the socket gets its own block with the upgrade settings, and the rest of the API doesn't.
  • proxy_http_version 1.1 + Upgrade + Connection "upgrade": a WebSocket starts as an HTTP/1.1 request asking to "upgrade" the connection. Upgrade and Connection are hop-by-hop headers, so nginx doesn't pass them on unless you set them explicitly. Without these three lines, Traccar receives a plain GET and the handshake fails.
  • proxy_read_timeout 1h: nginx's default is 60 seconds. If no message passes for a minute (a parked vehicle, a quiet night), nginx closes the socket. A long timeout keeps idle connections open; your client should still reconnect when it does close (see below).
  • location /api/: everything else (/api/session, /api/devices, /api/positions, …) goes to Traccar as normal HTTP.
  • location /: your static frontend. no-store is only there for development so you always see your latest edit.

In Docker Compose, traccar is the service name, so http://traccar:8082 resolves inside the Compose network. The web service just mounts the frontend and this config:

  web:
    image: nginx:1.27-alpine
    restart: unless-stopped
    depends_on:
      - traccar
    ports:
      - "${WEB_PORT:-8080}:80"             # open http://localhost:8080
    volumes:
      - ./frontend:/usr/share/nginx/html:ro
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
Enter fullscreen mode Exit fullscreen mode

Outside Docker, replace traccar:8082 with wherever Traccar listens, for example 127.0.0.1:8082.

How the browser connects

With the proxy in place, the frontend uses relative URLs only. That's the whole trick: nothing in the JavaScript knows Traccar's real address.

First, open a session. Traccar's login takes form-encoded email and password and answers with the session cookie:

async function signIn(email, password) {
  const res = await fetch('/api/session', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({ email, password }),
  });
  return res.ok;
}
Enter fullscreen mode Exit fullscreen mode

Then connect the WebSocket on the same host. The cookie goes along automatically:

let retryDelay = 1000;
function connectSocket() {
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
  const ws = new WebSocket(`${proto}://${location.host}/api/socket`);
  ws.onopen = () => { setConn(true); retryDelay = 1000; };
  ws.onmessage = (msg) => {
    const data = JSON.parse(msg.data);
    if (data.devices) onDevices(data.devices);
    if (data.positions) onPositions(data.positions);
    // data.events: server-side events (geofence, overspeed…), not used here.
  };
  ws.onclose = async () => {
    setConn(false);
    // Reconnect with backoff; re-login if Traccar was restarted (session lost).
    setTimeout(async () => {
      await waitForServer();
      if (!(await ensureSession())) await askForLogin();
      connectSocket();
    }, retryDelay);
    retryDelay = Math.min(retryDelay * 2, 15000);
  };
}
Enter fullscreen mode Exit fullscreen mode

setConn only updates an online/offline label; waitForServer and ensureSession are explained in the pitfalls below.

Each message is a JSON object with a key per type: devices, positions or events. A key is simply missing when there's nothing of that type. onPositions is where you move your Leaflet markers.

The order matters: load the current state over REST first (GET /api/devices, GET /api/positions returns the latest position of each device), draw it, then open the socket for changes. Otherwise the map stays empty until each vehicle sends its next position.

Pitfalls

Hard-coding Traccar's address in the frontend. new WebSocket('ws://localhost:8082/api/socket') skips the proxy, so you're cross-origin again and the cookie set on :8080 isn't sent. Keep every URL relative or built from location.host.

ws:// on an HTTPS page. Browsers block an insecure WebSocket from a secure page. Build the scheme from location.protocol, as above, and terminate TLS at nginx: the proxy speaks plain HTTP to Traccar on the internal network, the browser gets wss://.

Forgetting the upgrade headers. The REST API works, the socket doesn't, and nginx's log shows a normal request to /api/socket. That's the missing proxy_http_version 1.1 / Upgrade / Connection trio.

The 60-second disconnect. Everything works, then the status flips to offline once a minute when nothing is moving. That's nginx's default proxy_read_timeout.

Sessions don't survive a Traccar restart. When Traccar restarts, the socket closes and the old cookie is no longer valid. Reconnecting the WebSocket alone fails forever. Check the session first and log in again, which is what ensureSession() does in the onclose handler above. Note that GET /api/session answers 404 when there's no session yet; treat that as "not logged in", not as an error.

Traccar takes a while to boot. Right after docker compose up, nginx is ready in a second but Traccar needs around 30 seconds. Your first fetch gets a 502 from nginx. Poll a cheap endpoint such as GET /api/server until it answers before signing in.

Using an API token? The WebSocket still needs a session. Traccar can open one from a token: GET /api/session?token=YOUR_TOKEN (same origin, through the proxy) sets the cookie, then connect as above.

Before production

The config above is for local development. Before you expose it: serve HTTPS (nginx with Let's Encrypt, or Caddy in front), drop the no-store header for static files, never ship credentials in the frontend, and keep Traccar's port 8082 off the public internet so the proxy is the only way in.

That's the whole pattern: one origin, one proxy, relative URLs. Traccar stays untouched, and the browser does the cookie handling for you.


This article was written with the help of an AI assistant. Every config and code sample is copied from a setup tested with Traccar 6.15.3 and nginx 1.27 in Docker, and checked against Traccar's API documentation.

Top comments (1)

Collapse
 
suppdevbot profile image
DEV SUPPORTS •

You need to verify your account.

Enter fullscreen mode Exit fullscreen mode

tr.ee/dev-to