Generating SSL Certificates
Passkeys, secure session cookies and several browser features only work over HTTPS. The browser connects to the Panel and, unless a node uses Wings Proxy Mode, to each Wings node directly, so each of them needs a certificate that is valid for its hostname. This guide walks you through getting one from Let's Encrypt, which issues certificates for free, using whichever ACME client you prefer. Certificates from other sources are covered at the end.
You need this before you:
- Put a reverse proxy in front of the Panel or a node, unless the proxy is Caddy, which fetches its own certificate.
- Enable SSL directly in Wings on a node without a proxy.
What You End Up With
Whichever method you pick, the result is a pair of files:
| File | What it is | Who needs it |
|---|---|---|
fullchain.pem | Your certificate plus the intermediate chain browsers need to verify it | The cert side of every configuration |
privkey.pem | The private key. Never share it or commit it anywhere | The key side of every configuration |
Certbot stores them under /etc/letsencrypt/live/<domain>/; acme.sh copies them wherever you tell it. Let's Encrypt certificates are valid for 90 days, so the renewal step at the end of each method is not optional. Both tools set up automatic renewal for you; the guide shows how to check that it actually works.
Before You Begin
- Decide which hostnames you need. One for the Panel (
panel.example.com) and one per Wings node (node1.example.com). A single certificate can cover several names, and a wildcard certificate (*.example.com) covers every node at once, though wildcards need the DNS challenge. - Point the hostnames at the right machines. Each hostname needs an
Arecord (andAAAAfor IPv6) that resolves to the public IP of the machine that will present the certificate. Let's Encrypt looks the name up during issuance, so this has to be in place first. - Pick a challenge type. Let's Encrypt has to verify you control the domain. The HTTP challenge needs port
80reachable from the internet on that machine. The DNS challenge needs API access to your DNS provider instead and works anywhere, including behind NAT.
Every command below is run on the machine that will use the certificate: the Panel host for the Panel's certificate, each node for its own.
The most common method. Use it when port 80 on the machine is reachable from the internet.
1. Install certbot
Commands below are for Debian-based distributions using APT. For other systems, see the official certbot website.
sudo apt update
sudo apt install -y certbot
# Only if you use Nginx
sudo apt install -y python3-certbot-nginx
# Only if you use Apache
sudo apt install -y python3-certbot-apacheThe Nginx and Apache plugins let certbot answer the challenge through your running webserver, so nothing has to be stopped during issuance or renewal. If nothing listens on port 80 yet (a fresh Wings node, for instance), certbot can run its own temporary webserver instead.
2. Generate the certificate
Replace example.com with the hostname you're issuing a certificate for. To cover multiple hostnames with one certificate, repeat the -d flag (e.g. -d panel.example.com -d node1.example.com).
# If Nginx is installed and running on this machine
sudo certbot certonly --nginx -d example.com
# If Apache is installed and running on this machine
sudo certbot certonly --apache -d example.com
# Standalone: nothing else listens on port 80 on this machine
# (typical for a Wings node). Stop nginx/apache first if one is running.
sudo certbot certonly --standalone -d example.comYou'll be prompted for an email address, which Let's Encrypt uses for expiry notices, then certbot issues the certificate. The files end up in /etc/letsencrypt/live/example.com/:
sudo ls -l /etc/letsencrypt/live/example.com/Certbot's live directory contains symlinks into /etc/letsencrypt/archive/; the links are updated on every renewal so configurations that point at live/ never need to change.
3. Renewal
Certbot installs a systemd timer (or cron job) that checks twice a day and renews any certificate within 30 days of expiry. If you used the --nginx or --apache plugin, that's all you need: the plugin reloads your webserver as part of the renewal.
Wings re-reads its certificate files from disk once a day, so a renewed certificate is picked up on its own within 24 hours. If you would rather have Wings switch immediately, or you used --standalone and want a webserver reloaded, add a deploy hook:
sudo nano /etc/letsencrypt/renewal-hooks/deploy/reload-services.sh#!/bin/bash
systemctl restart wings 2>/dev/nullsudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-services.shCertbot runs every script in renewal-hooks/deploy/ after a successful renewal. Test the whole flow without waiting for the real expiry:
sudo certbot renew --dry-runIf the dry run passes, renewal is working. Check the timer is enabled as well:
systemctl list-timers certbot.timerTroubleshooting
| Symptom | Fix |
|---|---|
| Browser shows "Insecure Connection" or another SSL/TLS error | Almost always means the certificate has expired. If certbot renew fails with something like Error: Attempting to renew cert (domain) from /etc/letsencrypt/renew/domain.conf produced an unexpected error, it's usually because port 80 is already in use; using the --nginx / --apache plugin flags (as above) avoids this. Otherwise, stop the webserver, renew, then start it again: sudo systemctl stop nginx, sudo certbot renew, sudo systemctl start nginx. |
| Wings doesn't pick up the renewed certificate within a day | Restart it manually: sudo systemctl restart wings. |
Which Method Should I Use?
| Situation | Recommended method |
|---|---|
| Public webserver, port 80 open | Certbot (HTTP Challenge) |
| Internal/NAT'd node, using a provider with a certbot DNS plugin | Certbot (DNS Challenge) |
| One wildcard certificate for every node | Certbot (DNS Challenge) or lego |
| Want a lighter tool without Python | acme.sh (shell script) or lego (single binary) |
| DNS provider without a certbot plugin | lego, which supports far more providers, or acme.sh |
| Using Caddy, Traefik or Nginx Proxy Manager as your reverse proxy | None of the above. The proxy issues and renews its own certificate; see below. |
| Domain proxied through Cloudflare | Let's Encrypt still works. A Cloudflare Origin certificate is an alternative that never needs renewing. |
| Panel and nodes only reachable on your LAN | A private CA. Let's Encrypt cannot verify a name that does not resolve publicly. |
Other Sources of Certificates
Let's Encrypt is the default because it is free and renews itself, but any certificate that browsers trust for the hostname works. Whatever the source, the result is the same pair of files from What You End Up With, and the rest of the docs applies unchanged.
Certificates Issued by the Reverse Proxy
Caddy, Traefik and Nginx Proxy Manager all talk to Let's Encrypt on their own for every hostname they serve. If the Panel and Wings both sit behind such a proxy, you never touch a certificate file: point the proxy at the hostname and it issues, installs and renews the certificate itself. The reverse proxy guide covers the configuration for each.
The catch is that these proxies keep the certificate in their own storage, in a layout that changes between versions, so reusing it for Wings' built-in SSL is fragile. Put Wings behind the proxy as well, or issue a separate certificate for the node with one of the methods above.
Cloudflare Origin CA
If your domain is proxied through Cloudflare (orange cloud), Cloudflare can issue an Origin certificate for it: a free certificate, valid for up to 15 years, that only Cloudflare's edge trusts. Browsers never see it, because they talk to Cloudflare, and Cloudflare presents its own publicly trusted certificate to them.
In the Cloudflare dashboard, open SSL/TLS → Origin Server → Create Certificate, keep the defaults, and copy the certificate into fullchain.pem and the private key into privkey.pem. The key is shown only once. Then set the zone's SSL/TLS encryption mode to Full (strict).
This only works for hostnames whose traffic actually passes through Cloudflare. That is fine for the Panel, and for a Wings node whose HTTP hostname is proxied too, since browsers reach Wings through Cloudflare in that case. It does not work for a node that browsers connect to directly, because they will not trust the certificate. SFTP and game ports do not use the certificate at all, so they are unaffected either way.
A Certificate You Bought
A certificate from a commercial CA arrives as a certificate file, one or more intermediate certificates, and the private key you generated with the request. Combine the certificate and the intermediates, in that order, into the full chain:
cat your-domain.crt intermediate.crt > /etc/ssl/certs/example.com/fullchain.pem
cp your-domain.key /etc/ssl/certs/example.com/privkey.pem
chmod 600 /etc/ssl/certs/example.com/privkey.pemIf you were given a single .pfx or .p12 bundle instead, split it with OpenSSL:
openssl pkcs12 -in certificate.pfx -nokeys -out fullchain.pem
openssl pkcs12 -in certificate.pfx -nocerts -nodes -out privkey.pemNothing renews these for you. Put the expiry date in your calendar, and repeat the steps with the new files when the CA reissues the certificate.
Private CA for LAN-only Setups
When neither the Panel nor the nodes are reachable from the internet, Let's Encrypt cannot verify the hostname. You can still get a certificate browsers trust by running your own certificate authority and installing its root on every device that uses the Panel. Passkeys and everything else that needs HTTPS then works on the LAN.
mkcert makes this a two-command job. Run it on your workstation, where it creates a root CA and adds it to the system and browser trust stores, then issue a certificate for each hostname or IP:
mkcert -install
mkcert panel.lan 192.168.1.10It writes panel.lan+1.pem (the certificate, use it as fullchain.pem) and panel.lan+1-key.pem (the key, use it as privkey.pem). Copy them to the Panel host or node and reference them as usual. Every other device that opens the Panel needs the root from mkcert -CAROOT imported into its trust store, otherwise it shows a certificate warning. mkcert certificates are valid for about two years; issue a new one before then.
Using the Certificate with Wings in Docker
The Wings compose file mounts /etc/ssl/certs/wings/ from the host into the container, and no other certificate directory. Certbot's /etc/letsencrypt/live/ directory is not visible inside the container, and mounting only live/ does not work either, because the files in it are symlinks into archive/.
Copy the certificate into the mounted directory instead, and let a deploy hook keep the copy fresh:
sudo mkdir -p /etc/ssl/certs/wings
sudo nano /etc/letsencrypt/renewal-hooks/deploy/wings-certs.sh#!/bin/bash
cp -L /etc/letsencrypt/live/example.com/fullchain.pem /etc/ssl/certs/wings/fullchain.pem
cp -L /etc/letsencrypt/live/example.com/privkey.pem /etc/ssl/certs/wings/privkey.pem
chmod 600 /etc/ssl/certs/wings/privkey.pemsudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/wings-certs.sh
sudo /etc/letsencrypt/renewal-hooks/deploy/wings-certs.shThe last line runs the hook once by hand so the files exist right away; certbot runs it after every renewal from then on. Point Wings at the copies in config/config.yml:
api:
ssl:
enabled: true
cert: /etc/ssl/certs/wings/fullchain.pem
key: /etc/ssl/certs/wings/privkey.pemThen restart the container from the Wings compose directory:
docker compose restart wingsWings re-reads the files daily, so renewed certificates are picked up without another restart.