Skip to main content

🔒 Reverse Proxy

Most deployments run alpine-moodle behind a reverse proxy that terminates TLS. This page documents how to wire that up correctly and how to avoid the recurring pitfalls reported in issues like #15, #21, #51, #57, #61, #101, #127 and #137.

How Moodle sees the world​

Moodle builds every URL from $CFG->wwwroot. That value is derived from SITE_URL at first start. If wwwroot does not exactly match the URL the browser uses, you will see:

  • Broken CSS / JS — assets are generated against a different origin, triggering mixed content or 404s.
  • ERR_TOO_MANY_REDIRECTS — Moodle keeps redirecting to its canonical wwwroot because the incoming request looks different.
  • Login loops — session cookies are set on a host Moodle does not recognise.

To use a reverse proxy correctly you need to tell Moodle two things:

  1. The public URL users type in the browser → SITE_URL
  2. Whether the public connection is HTTPS even though the container speaks HTTP → SSLPROXY=true

The correct settings​

environment:
SITE_URL: https://moodle.example.com
SSLPROXY: "true"
REVERSEPROXY: "false"
VariableValuePurpose
SITE_URLFull public URL with schemeBecomes $CFG->wwwroot. Must match what users type in the browser.
SSLPROXYtrueTrusts X-Forwarded-Proto so Moodle treats the request as HTTPS.
REVERSEPROXYfalse in most casesOnly set to true if the same site is intentionally accessed under multiple base URLs (multi-tenant / multi-host). See Moodle docs on reverse proxies.
"REVERSEPROXY=true is rarely what you want"

Setting REVERSEPROXY=true on a single-URL deployment triggers "Reverse proxy enabled so the server cannot be accessed directly" errors (issue #137). Leave it at false unless you actually serve the same site from several hostnames.

Required proxy headers​

Whatever proxy you use, it must forward these headers:

  • Host — the public hostname
  • X-Forwarded-Proto — https
  • X-Forwarded-For — the real client IP

Without X-Forwarded-Proto: https the SSLPROXY flag has nothing to trust and Moodle will keep redirecting to HTTP.

Traefik (v2 / v3)​

Label-based configuration, no file changes required on the Traefik side.

services:
moodle:
image: ruseler/alpine-moodle
restart: unless-stopped
environment:
SITE_URL: https://moodle.example.com
SSLPROXY: "true"
DB_HOST: postgres
DB_USER: moodle
DB_PASS: moodle
DB_NAME: moodle
MOODLE_USERNAME: admin
MOODLE_PASSWORD: ChangeMe123!
volumes:
- moodledata:/var/www/moodledata
- moodlehtml:/var/www/html
networks:
- proxy
- default
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.moodle.rule=Host(`moodle.example.com`)"
- "traefik.http.routers.moodle.entrypoints=websecure"
- "traefik.http.routers.moodle.tls=true"
- "traefik.http.routers.moodle.tls.certresolver=letsencrypt"
- "traefik.http.services.moodle.loadbalancer.server.port=8080"

networks:
proxy:
external: true

Traefik forwards X-Forwarded-Proto automatically when the entry point terminates TLS, so nothing else is needed.

"502 Bad Gateway with Traefik"

A 502 from Traefik (issue #61) usually means the service port is wrong. The container listens on 8080, not 80. Set traefik.http.services.moodle.loadbalancer.server.port=8080.

Nginx​

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

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

client_max_body_size 100M;

location / {
proxy_pass http://moodle:8080;
proxy_http_version 1.1;

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 https;
proxy_set_header X-Forwarded-Host $host;

proxy_read_timeout 300s;
}
}

server {
listen 80;
server_name moodle.example.com;
return 301 https://$host$request_uri;
}

Set client_max_body_size high enough to allow large course uploads. Also raise the matching PHP variables (post_max_size, upload_max_filesize) on the Moodle container.

Nginx Proxy Manager​

NPM is popular for homelab deployments and is the source of several support questions (#51).

  1. Expose the container on an internal Docker network that NPM can reach (no host port required).

  2. In NPM, create a Proxy Host:

    • Domain Names: moodle.example.com
    • Scheme: http
    • Forward Hostname / IP: the container name (e.g. moodle)
    • Forward Port: 8080
    • Toggle Block Common Exploits: off (NPM's WAF can break some Moodle paths)
    • Toggle Websockets Support: on
  3. On the SSL tab, request a Let's Encrypt certificate and enable Force SSL and HTTP/2.

  4. Under Advanced, add:

    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header X-Forwarded-Host $host;
    client_max_body_size 100M;
  5. In your docker-compose.yml for Moodle, set SITE_URL=https://moodle.example.com and SSLPROXY=true.

Apache (mod_proxy)​

<VirtualHost *:443>
ServerName moodle.example.com

SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/moodle.example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/moodle.example.com/privkey.pem

ProxyPreserveHost On
ProxyRequests Off

RequestHeader set X-Forwarded-Proto "https"
RequestHeader set X-Forwarded-Port "443"

ProxyPass / http://moodle:8080/
ProxyPassReverse / http://moodle:8080/

<Proxy *>
Require all granted
</Proxy>
</VirtualHost>

<VirtualHost *:80>
ServerName moodle.example.com
Redirect permanent / https://moodle.example.com/
</VirtualHost>

Enable the required modules once:

a2enmod proxy proxy_http ssl headers rewrite

Caddy​

moodle.example.com {
reverse_proxy moodle:8080 {
header_up Host {host}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-For {remote}
}
}

With SITE_URL=https://moodle.example.com and SSLPROXY=true on the Moodle container this is all you need — Caddy will obtain and renew TLS automatically.

Cloudflare / Cloudflared​

If you front Moodle with Cloudflare (proxied DNS or cloudflared tunnel), Cloudflare already injects X-Forwarded-Proto: https. You only need:

environment:
SITE_URL: https://moodle.example.com
SSLPROXY: "true"
REVERSEPROXY: "false"

To log the real visitor IP instead of the Cloudflare edge IP, enable the CF-Connecting-IP header in your front proxy (or use Cloudflare's True-Client-IP) and ensure the next proxy hop copies it into X-Forwarded-For.

Reverse proxy with a URL prefix​

Moodle does not support being served from a subpath such as https://example.com/mylms/ (issue #127). Upstream Moodle requires its own hostname or subdomain. If you need path-based routing, use a dedicated subdomain (moodle.example.com) instead.

Checklist​

Before opening a support issue, verify:

  • SITE_URL is the public URL, starts with https://, no trailing slash.
  • SSLPROXY=true is set when the proxy terminates TLS.
  • REVERSEPROXY=false (unless you really use multiple base URLs).
  • The proxy forwards Host, X-Forwarded-Proto and X-Forwarded-For.
  • The upstream port is 8080, not 80.
  • You restarted the container after changing SITE_URL on an existing installation and, if necessary, manually updated wwwroot in config.php.

This documentation is a fork of erseco/alpine-moodle. The upstream documentation is available here.