Skip to content

Putting Wings Behind a Reverse Proxy ​

This guide covers standalone Wings nodes. The Wings bundled with the All-in-One image is covered by the Panel guide. For how a proxy fits into the request path, see Setting up a Reverse Proxy.

INFO

These configurations target current releases of each proxy and of Wings. After updating Wings, compare your configuration with this page again; see Keeping the Configuration Current.

You have three ways to secure a standalone node, and most setups only need the first:

ApproachWhen to use it
Wings' built-in SSLThe node runs nothing else on ports 443/8080. Point Wings at the certificate files and you're done. No proxy involved.
Reverse proxy in front of Wings (this guide)A proxy already runs on the node, you want Wings on port 443, or you want one place to manage certificates.
Wings Proxy ModeThe node can't be reached from the internet at all. The Panel relays browser traffic to it.

With the first two options, browsers connect to Wings directly for the console, file uploads and downloads, so the node's public URL must be reachable from your users' machines. Only Wings Proxy Mode routes that traffic through the Panel instead.

Prerequisites ​

  • Wings installed, reachable at http://<node-ip>:8080.
  • A domain for the node (<node-domain> below) with an A record, plus AAAA for IPv6 pointing at it.
  • Ports 80 and 443 open, and forwarded on your router if the node is at home.
  • A TLS certificate for the domain. Caddy, Traefik and Nginx Proxy Manager issue their own.
  • The proxy installed on the node, or running in Docker (see Proxies Running in Docker).

WARNING

A broken proxy configuration makes the node unreachable until it's fixed, so keep a terminal open.

Step 1: Prepare Wings ​

Everything in this step happens on the node, mostly in config.yml (/etc/calagopus-wings/config.yml, or config/config.yml in the Wings compose directory). The Panel never pushes api.host, api.port, api.ssl or api.trusted_proxies, so they can only be set here. Restart Wings once at the end.

Turn Off Wings' Own SSL ​

The proxy terminates TLS and forwards to Wings over plain HTTP. With api.ssl still enabled, Wings answers that with a TLS handshake and every request fails with 502:

yaml
api:
  ssl:
    enabled: false

This is the default on a fresh install, so it only needs changing on a node that ran with its own certificate before.

Stop Exposing Port 8080 ​

Wings listens on every interface by default (api.host is 0.0.0.0), so port 8080 stays reachable from the internet even after the proxy is in place. Restrict it to the loopback interface:

yaml
api:
  host: 127.0.0.1
  port: 8080
yaml
# In compose.yml, not config.yml. api.host stays 0.0.0.0 inside the container.
services:
  wings:
    ports:
      - 127.0.0.1:8080:8080
      - 2022:2022 # SFTP does not go through the proxy, keep it public

Keep api.port at 8080. It is the port every configuration in Step 2 forwards to.

Using a Unix socket instead

Point api.host at a path instead of an IP and Wings serves plain HTTP on a Unix socket, with no TCP port at all. It reports every request as coming from 127.0.0.1, which is what api.trusted_proxies below then needs. api.ssl is ignored, and this needs a binary or package install on Unix, where Wings and the proxy share a filesystem.

yaml
api:
  host: /run/calagopus-wings/api.sock

Create the directory first, with permissions the proxy's user can traverse, then point the proxy at the socket:

ProxyTarget
Nginxserver unix:/run/calagopus-wings/api.sock; in the upstream block, in place of server 127.0.0.1:8080;
ApacheProxyPass / unix:/run/calagopus-wings/api.sock|http://localhost/ retry=0 upgrade=websocket
Caddyreverse_proxy unix//run/calagopus-wings/api.sock

Trust the Proxy's Address ​

Like the Panel, Wings needs to know which address the proxy connects from before it believes the forwarded IP headers. It uses them for its per-IP WebSocket connection limits and for the IP recorded on file-upload activity, so without this every user shares one budget.

Set api.trusted_proxies:

yaml
# Wings listens on 127.0.0.1, so the proxy connects from the same machine:
api:
  trusted_proxies:
    - 127.0.0.1
yaml
# Print the gateway address inside the Wings compose directory:
#   docker inspect -f '{{range .NetworkSettings.Networks}}{{println .Gateway}}{{end}}' $(docker compose ps -q wings)
api:
  trusted_proxies:
    - 172.20.0.1

The list takes IPs or CIDR ranges. When a request arrives from one of them, Wings reads X-Forwarded-For (walking the list from the right and skipping every trusted address) and falls back to X-Real-IP. When it arrives from anywhere else, both headers are ignored. Trusting too much lets a visitor spoof their IP by sending the header themselves.

You don't need to trust the Panel here. It only relays consoles and uploads in Wings Proxy Mode, and there it authenticates the forwarded IP with the node token, which works as long as the Panel reaches Wings directly. If a proxy-mode node's URL points at this proxy instead, the proxy's X-Forwarded-For wins and Wings records the Panel's address.

Editing a bind-mounted config is awkward

Any configuration key can also be set with a CALAGOPUS_-prefixed environment variable, which Wings re-applies after every configuration push from the Panel. On the wings service in compose.yml:

yaml
    environment:
      CALAGOPUS_API_TRUSTED_PROXIES: '["172.20.0.1"]'

Apply the Changes ​

bash
sudo systemctl restart wings
bash
docker compose up -d

Then confirm Wings answers on the loopback address, and only there:

bash
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/api/system

A 401 is the expected answer, since the request carries no token. It proves Wings is listening and the proxy will be able to reach it. From another machine, http://<node-ip>:8080 should now time out or be refused.

Step 2: Configure the Proxy ​

Every configuration below does the same things. If you use a proxy that isn't listed, these are the settings to replicate:

SettingWhy Wings needs it
Forward to http://127.0.0.1:8080The address Wings listens on after Step 1
Pass Upgrade and Connection throughThe console, live statistics, transfers and the node's system log use WebSockets
Allow HEAD, PATCH and DELETEResumable uploads need all three: HEAD reads the current offset, PATCH appends a chunk, DELETE discards a cancelled upload
No request body limitServer transfers and file copies from another node arrive as one chunked request per stream, carrying the whole server. Wings lifts its own limit for them, and caps uploads with api.upload_limit itself
Request buffering offWith it on, the proxy reads a whole upload chunk or transfer to disk before Wings sees a byte of it
Response buffering offBackups and file archives stream from disk and can be many gigabytes. With buffering on, the proxy keeps reading ahead of the client and writing the excess to temporary files
Read and write timeouts above 60sA slow client moving a multi-gigabyte file, or another node transferring a server, holds one connection open far longer than the defaults allow
X-Forwarded-For or X-Real-IPThe client IPs from Step 1
No CORS headersWings sets Access-Control-Allow-Origin from the Panel URL it was configured with, and exposes Upload-Offset for resumable uploads. A second copy from the proxy makes the browser reject every response
No X-Robots-TagEvery endpoint answers 401 without a token, so there is nothing to crawl

Other nodes use this proxy too: a server transfer or file copy to this node is POSTed by the source node's Wings to /api/transfers on this node's URL.

Only HTTP goes through the proxy. SFTP (port 2022), the private network tunnel and the game server ports all connect to the node directly, which works as long as the node's hostname resolves to its real IP.

1. Add the WebSocket map to /etc/nginx/nginx.conf, inside http { ... } and outside any server { ... } block, if this node doesn't have it yet:

nginx
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      "";
}

This sends Connection: upgrade only on requests that actually ask for a WebSocket. Without it Nginx sends the header on every request, and multipart uploads and other ordinary traffic break.

2. Create the site as /etc/nginx/sites-available/calagopus-wings.conf on Debian and Ubuntu, or /etc/nginx/conf.d/calagopus-wings.conf on RHEL-based systems.

nginx
# Replace <node-domain> with the node's domain everywhere below.
# Change the address in the upstream if Wings doesn't listen on 127.0.0.1:8080.
upstream calagopus-wings {
    server 127.0.0.1:8080;
    keepalive 16;
}

server {
    listen 80;
    listen [::]:80;
    server_name <node-domain>;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name <node-domain>;

    access_log /var/log/nginx/calagopus-wings.access.log;
    error_log  /var/log/nginx/calagopus-wings.error.log error;

    # The paths certbot creates. Change them if your certificate lives elsewhere.
    ssl_certificate     /etc/letsencrypt/live/<node-domain>/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/<node-domain>/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers on;

    # Leave at 0: server transfers from other nodes arrive as one request.
    client_max_body_size 0;

    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy        "same-origin" always;

    location / {
        proxy_http_version 1.1;
        # WebSocket support (uses the map from step 1)
        proxy_set_header Upgrade          $http_upgrade;
        proxy_set_header Connection       $connection_upgrade;
        # Tell Wings who the visitor is
        proxy_set_header Host             $host;
        proxy_set_header X-Real-IP        $remote_addr;
        proxy_set_header X-Forwarded-For  $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        # Stream uploads and downloads instead of spooling them to disk
        proxy_buffering off;
        proxy_request_buffering off;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
        send_timeout       3600s;
        proxy_redirect off;
        proxy_pass http://calagopus-wings;
    }
}

The upstream block names Wings once, and keepalive 16 lets each Nginx worker keep up to 16 idle connections to it open for reuse instead of opening a new one for every request. Its name differs from the Panel's, so both sites can share one Nginx.

3. Enable it and reload. On Debian and Ubuntu, link the site into sites-enabled. On RHEL-based systems the file in conf.d/ is already active.

bash
sudo ln -s /etc/nginx/sites-available/calagopus-wings.conf /etc/nginx/sites-enabled/calagopus-wings.conf
sudo nginx -t
sudo systemctl reload nginx

nginx -t checks the configuration before anything is reloaded. If it reports an error, fix the file first; the running Nginx keeps its old configuration until the reload succeeds.

Nginx older than 1.25.1

The standalone http2 on; directive was added in 1.25.1. On older releases, drop that line and put the parameter back on the listen directives instead:

nginx
    listen 443 ssl http2;
    listen [::]:443 ssl http2;

Step 3: Point the Panel at the Proxied URL ​

In Admin → Nodes → (your node) → General, set URL to https://<node-domain> without a port. The proxy listens on 443, which is what the URL implies. The form warns that no port was given and offers to add :8080; ignore it here, since :8080 would bypass the proxy. Leave Public URL empty unless you want browsers to use a different address than the Panel does.

Then open the node's Configuration tab and run Verify Connection. Both checks have to pass: Backend to Wings proves the Panel reaches the node through the proxy, and Frontend to Wings proves your browser does, which the console, uploads and downloads depend on.

Finish with a real transfer: open the console, upload a file over 95 MiB so a resumable chunk goes through the proxy, and download a backup. If you move servers between nodes, transfer one to this node too.

Docker and Cloudflare ​

If your proxy runs in Docker, or the node sits behind Cloudflare, the same adjustments as the Panel apply, just aimed at Wings' port 8080 instead of the Panel's 8000:

  • Proxies Running in Docker: forward to the Wings service name over a shared Docker network instead of a published port, and trust that network's subnet in api.trusted_proxies instead of a single gateway address.
  • Cloudflare: trust Cloudflare's IP ranges in api.trusted_proxies alongside your proxy's address, set the zone's SSL/TLS mode to Full (strict), and keep SFTP, game ports and the private network tunnel on a DNS-only (grey cloud) hostname, since none of them are HTTP.

Cloudflare is a poor fit for a node hostname either way: its per-plan request size cap applies to uploads, and its request duration limit cuts long backup downloads off with a 524. A DNS-only record for the node avoids both. See Backup configurations.

Troubleshooting ​

SymptomFix
502 Bad Gateway, or the proxy's own error pageThe proxy can't reach Wings. Check that it's running with systemctl status wings or docker compose ps, and that curl -I http://127.0.0.1:8080/api/system answers on the node. If every request fails immediately, api.ssl.enabled is probably still true while the proxy forwards plain HTTP; see Step 1. If the proxy runs in Docker, make sure both containers are on the same network and the forward target is the service name, not 127.0.0.1.
The console stays on "connecting" and live statistics never appearWebSocket upgrades aren't getting through. On Nginx, confirm the map block exists in nginx.conf and both Upgrade and Connection headers are set. On Apache, check the version note above. On Nginx Proxy Manager, enable Websockets Support.
Uploads, server transfers or file copies to this node fail with 413 Request Entity Too LargeThe proxy still has a body limit. Set client_max_body_size 0 on Nginx or Nginx Proxy Manager, LimitRequestBody 0 on Apache, and remove any request_body block on Caddy.
Uploads fail with 417 Expectation Failed and a message about the file sizeThat one is Wings, not the proxy: the file is larger than api.upload_limit. Raise it in config.yml and restart Wings. The proxy's body limit does not need to change with it.
Large uploads, downloads or server transfers die partway through, always after about the same timeA proxy timeout. Raise proxy_read_timeout, proxy_send_timeout and send_timeout on Nginx, or ProxyTimeout on Apache. On Traefik, set respondingTimeouts.readTimeout to 0, since it counts the whole request rather than the gap between reads.
Downloads fill the proxy's disk with temporary filesResponse buffering is on. Set proxy_buffering off on Nginx, or add it to Custom Nginx Configuration on Nginx Proxy Manager. Caddy and Traefik stream by default.
An upload resumes from 0 every time, or the browser console complains about a missing Upload-Offset headerThe proxy is dropping the response header Wings exposes for resumable uploads, or answering the HEAD and PATCH requests itself. Remove any CORS or method restrictions you added; Wings sets Access-Control-Expose-Headers: Upload-Offset itself.
Every request fails with a CORS error in the browser consoleEither the proxy adds its own Access-Control-Allow-Origin next to Wings', which browsers reject, or the Panel URL under Admin → Settings → Application no longer matches the address the browser is using. Wings allows exactly the origin the Panel told it about.
Rate limits or connection limits trigger for everyone at once, as if every user shares one budgetapi.trusted_proxies doesn't contain the address the proxy connects from. Re-check Step 1; the address can change if the compose network was recreated.
"Frontend to Wings" fails while "Backend to Wings" passesThe Panel can reach the node but your browser can't. Usually the node's certificate isn't valid for the hostname, the hostname doesn't resolve publicly, or port 443 is blocked between you and the node.
Browser shows a certificate warningThe certificate has expired or was issued for a different name. See Generating SSL Certificates for renewal problems.

Problems that aren't caused by the proxy, such as the node URL, tokens or clock skew, are collected on the Troubleshooting page.