Skip to content

Networking, domains and TLS

Every app has an exposure mode, chosen when you create it. It can’t be changed afterwards. The mode decides how the app reaches the network:

Mode Port Nginx / domains Reachable from Firewall
web (default) yes vhost, custom domains, TLS the internet through Nginx on 80/443 nothing app-specific
tcp yes none the internet directly on the app’s port ufw allow <port>/tcp while publiclyReachable is true
internal yes none other processes on this host via 127.0.0.1:<port> never opened
process none none nothing, it’s a long-running process never opened

Static sites are always web. Celery apps can’t use process (they already run portless workers, and their port belongs to Flower).

  • web: HTTP APIs and websites. This is the only mode that can have domains and TLS.
  • tcp: protocols that aren’t HTTP, such as game servers, MQTT, gRPC without a proxy, or custom binary protocols.
  • internal: a service only your other apps call, such as an internal API or sidecar.
  • process: queue workers, bots, schedulers and scrapers that listen on nothing. See Run gisila_queue workers.
  • Don’t use tcp for an HTTP service just to skip Nginx. You lose TLS termination, domains and the health check path.
  • A tcp, internal or process app on python, node, bun or rust must have a start command. The default for those runtimes starts an HTTP server (gunicorn or the detected web framework), which is the wrong program. The API refuses to create such an app without one.

Apps with a port pick it themselves (internalPort, 1024 to 65535). It must be unique on the host. The panel passes it to the process as the PORT environment variable. See the App runtime contract.

For a web app, the agent writes an Nginx vhost that proxies the app’s domains to 127.0.0.1:<port>.

To attach a domain:

  1. Point a DNS A (and AAAA if you use IPv6) record for the hostname at the server. GET /system/info returns the address to use (publicIp, overridable with PUBLIC_IP in /etc/gisila/.env).
  2. POST /apps/{id}/domains/ with {"hostname": "api.example.com"}. The worker rewrites the vhost. A hostname can belong to only one app.
  3. POST /apps/{id}/domains/{domainId}/ssl queues certificate issuance. The agent runs certbot --nginx -d <hostname> and reloads Nginx. The domain’s ssl_status moves from none to pending, then to issued or failed.

Issuance uses the HTTP-01 challenge, so the hostname must already resolve to this server and port 80 must be reachable. Certificates are renewed by certbot’s own timer, not by the panel. The same route re-issues a certificate.

Domains are rejected for tcp, internal and process apps, since there is no vhost to attach a hostname to.

  • A static app is served straight from its published release. staticRoot picks the directory inside the repo (for example dist), and staticSpa: true falls back to index.html for unknown paths. Changing either re-renders the vhost without a rebuild.
  • With mediaEnabled: true, a web app gets a persistent shared/media directory. Nginx serves it publicly at /media/ and through an internal /_protected/ location the app can hand off to with X-Accel-Redirect for downloads that need auth. The app receives MEDIA_ROOT and MEDIA_URL env vars. mediaMaxUploadMb (default 25) sets client_max_body_size.

A tcp app binds 0.0.0.0:<port> itself. After each deploy, and whenever you call POST /apps/{id}/network with {"publiclyReachable": …}, the worker opens or closes ufw for that port, with no rebuild or restart. Closing a port never touches the host’s SSH ports. Non-web apps also get LimitNOFILE=65536 instead of 4096, since every client connection is a socket held by the app.

If all your sites are proxied through Cloudflare, a superuser can limit ports 80 and 443 to Cloudflare’s ranges with POST /firewall/web-sources ({"preset": "cloudflare"}). Sites whose DNS isn’t proxied through those sources stop loading, and their HTTP-01 renewals fail. See Change the firewall safely.

Attach a domain and issue a certificate:

Terminal window
H='Authorization: Bearer gsl_xxxxxxxxxxxxxxxx'
J='Content-Type: application/json'
DOMAIN_ID=$(curl -sX POST https://panel.example.com/apps/42/domains/ \
-H "$H" -H "$J" -d '{"hostname": "api.example.com", "isPrimary": true}' | jq .id)
curl -sX POST "https://panel.example.com/apps/42/domains/$DOMAIN_ID/ssl" -H "$H"
curl -s https://panel.example.com/apps/42/domains/ -H "$H" | jq '.results[] | {hostname, ssl_status}'