Factorio Admin RCON Source code

Deployment

Four ways to reach the panel, from a laptop to a public domain — and the one setting that decides whether your rate limiting is real.

The panel binds to 127.0.0.1 and nothing else. That is not a default waiting to be changed: it grants full RCON access to whoever signs in, so making it reachable should be a decision you take on purpose. Every recipe below keeps the container on loopback and puts something in front of it.

TRUST_PROXY is not cosmetic Login attempt limiting is per IP. Without TRUST_PROXY=true the panel ignores X-Forwarded-For — deliberately, because a forgeable header must not drive rate limiting — so the per-IP limiter is skipped entirely and only the global cap is left standing (src/server/http/context.ts). With it set behind a proxy that does not overwrite the header, an attacker picks their own identity and the limiter is bypassable. Set it only behind a proxy you control that rewrites the header itself. All four recipes below do.

Local, on your own machine#

Nothing in front, nothing to configure. This is the quickstart, and it is also the right answer for a server you administer over SSH — forward the port instead of publishing it.

docker compose up -d
# → http://127.0.0.1:3010

# From another machine, without exposing anything:
ssh -N -L 3010:127.0.0.1:3010 you@your-server

Caddy#

The shortest path to HTTPS: Caddy obtains and renews the certificate on its own, and sets X-Forwarded-For itself.

factorio.example.com {
    reverse_proxy 127.0.0.1:3010
}

Then add the variable to the factorio-admin service:

environment:
  - TRUST_PROXY=true

Nginx#

The proxy_set_header lines are the point: Nginx passes an inbound X-Forwarded-For through unless you overwrite it, and $proxy_add_x_forwarded_for appends rather than replaces. Setting it to $remote_addr discards whatever the client claimed.

server {
    listen 443 ssl http2;
    server_name factorio.example.com;

    ssl_certificate     /etc/letsencrypt/live/factorio.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/factorio.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:3010;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-For   $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Traefik#

With Traefik on the same compose network, drop the port publishing entirely and label the service. Traefik writes X-Forwarded-For itself for any client that is not in trustedIPs.

factorio-admin:
  # No `ports:` at all — Traefik reaches the container over the network.
  environment:
    - TRUST_PROXY=true
  labels:
    - "traefik.enable=true"
    - "traefik.http.routers.factorio-admin.rule=Host(`factorio.example.com`)"
    - "traefik.http.routers.factorio-admin.entrypoints=websecure"
    - "traefik.http.routers.factorio-admin.tls.certresolver=letsencrypt"
    - "traefik.http.services.factorio-admin.loadbalancer.server.port=3000"

Tailscale#

The option with the smallest attack surface: no public port, no certificate to renew, no reverse proxy to keep patched. The panel is reachable from your tailnet and from nowhere else, which suits an admin console better than a public hostname does.

# On the host running the stack
tailscale serve --bg --https=443 http://127.0.0.1:3010

# → https://your-host.your-tailnet.ts.net
tailscale serve status
TRUST_PROXY here too tailscale serve terminates TLS and forwards over loopback, so it is a reverse proxy like the others: set TRUST_PROXY=true, or every tailnet client shares one rate-limit bucket. Do not use tailscale funnel unless you mean to publish the panel to the whole internet.

Checking it worked#

# Readiness: config, command catalogue, database and RCON, one object each.
curl -s https://factorio.example.com/api/ready | python3 -m json.tool

# The cookie must come back Secure over HTTPS.
curl -si https://factorio.example.com/api/login \
  -H 'Content-Type: application/json' -H 'Origin: https://factorio.example.com' \
  -d '{"password":"wrong"}' | grep -i set-cookie

A failed login should answer 401, and repeated attempts should start answering 429. If they never do, the panel is not seeing distinct client addresses — re-read TRUST_PROXY above.