HTTPS and TLS
This page is the only supported way to publish ToBeAlert at https://your-domain with Docker Compose.
The frontend container speaks HTTP on port 8080. It does not terminate TLS. An extra nginx container (tobealert-https) terminates TLS on 443 and forwards to the frontend. The backend speaks HTTPS on 9000 with the same certificate, because the browser calls the API on that port.
Do the steps in order. Do not skip the verification commands. Each command below includes the expected result. If yours differs, go to If something fails — do not improvise.
You need a stack that already answers HTTP: Deploy ToBeAlert.
How traffic actually flows
Three URLs, three different jobs. Mixing them is the usual failure.
| URL you type | Who answers | What it is for |
|---|---|---|
https://host/tobealert/ | TLS proxy → frontend | The Web UI. This is what people open. |
https://host:9000/api/management/healthcheck | Backend TLS | The API the UI uses. API_URL must be https://host:9000 with no path. |
https://host/api/management/healthcheck | TLS proxy → frontend nginx location /api/ → backend | Extra path. Useful to test the frontend proxy. Not what the UI uses when API_URL includes :9000. |
https://host/api (no slash, no path) | Frontend location / | Not the API. Nginx redirects to /tobealert. |
Frontend nginx matches location /api/ (slash required). A request to /api does not match it and is redirected to the UI.
The UI JavaScript loads /tobealert/config.json, which sets apiUrl to API_URL + /api/. Example: API_URL=https://alerts.example.com:9000 → browser calls https://alerts.example.com:9000/api/….
What you must never do
| If you do this | What you will see |
|---|---|
"443:443" on tobealert-frontend | curl https:// → SSL_ERROR_SYSCALL. Nothing inside that container listens on 443. |
Leave nginx_data:/etc/nginx/conf.d on the frontend | Every start overwrites default.conf with listen 8080 only. Old listen 443 ssl is gone. |
Leave certificate_data:/etc/ssl/certs on the frontend | That path is the system CA store. Overlaying it hides the real CAs. The volume is often hundreds of .pem files. Picking the first .pem is a CA, not your server cert. |
API_URL: https://host/backend | Browser calls /backend/api/…. There is no /backend location. API “does not exist”. |
API_URL: https://host/api | config.json appends /api/ again → /api/api/…. |
API_URL: http://… while BASE_URL is https://… | Mixed content. UI loads, API blocked by the browser. |
docker compose down -v | Deletes certificate_data. The old certificates are gone. |
Opening https://host/api in the browser | Redirect to /tobealert. This is not a backend 404. |
Calling https://127.0.0.1/ | Certificate name mismatch. Use the DNS name. |
# in DATABASE_URL unescaped | Backend never reaches Postgres. Write # as %23. |
Before you change anything
On the host, in the directory of docker-compose.yaml:
docker compose ps
curl -sI http://127.0.0.1/tobealert/
docker exec tobealert-frontend grep listen /etc/nginx/conf.d/default.conf
Expected:
- Four containers up:
bbdd,redis,backend,frontend(HTTPS proxy not there yet). - HTTP
301or200involving/tobealert. listen 8080;inside the frontend. If you still seelisten 80/listen 443, you are not on a current image.
If HTTP does not work yet, stop. Fix HTTP first (Docker).
Find the real certificate (upgrades)
Older images terminated TLS inside the frontend:
ports:
- "80:80"
- "443:443"
volumes:
- nginx_data:/etc/nginx/conf.d
- certificate_data:/etc/ssl/certs
That still works only on those old images. Current images listen on 8080 and cannot bind 443.
The files are still in the Docker volume. The volume is often a copy of /etc/ssl/certs, so it contains many CA certificates. Do not use the first .pem.
Run this before any helper script. Use the volume name from docker volume ls (Compose may prefix it, for example tobeit_certificate_data).
docker volume ls | grep -E 'certificate_data|nginx_data'
# Private key — this is YOUR file. CA stores almost never contain a .key
docker run --rm -v certificate_data:/certs:ro alpine \
sh -c 'find /certs -maxdepth 1 -type f | sed "s|.*/||" | grep -Ei "\.key$|key\.pem$"'
# What the OLD nginx pointed at (may be empty if default.conf was already rewritten)
docker run --rm -v nginx_data:/conf:ro alpine \
sh -c 'grep -rE "ssl_certificate" /conf || true'
Expected:
- Exactly one (or two)
*.key/*key*.pemlines — that is the private key. - The certificate is the
.pem/.crtwith the same basename, or the path inssl_certificate(notssl_certificate_key). - Names such as
ISRG,DST,GlobalSign,ca-certificates,ca-bundleare CAs. Ignore them.
If you see dozens of .pem files and no .key, the server certificate is not in that volume (or it was never a key file). Put the PEM + key you received from the CA into ./certs/ as fullchain.pem and privkey.pem and skip volume reuse.
Do not run docker compose down -v.
Auto HTTPS
Fill in the hostname and the compose directory. Copy the script onto the server and run it there.
The script refuses to guess when it cannot uniquely identify the key. If the volume looks like the CA store, it copies only the two matching files into ./certs/ instead of mounting hundreds of CAs.
Fill in the public hostname and the folder that contains docker-compose.yaml. Copy the script onto the server and run it there. It will not pick a random CA .pem. It looks for a private key and for ssl_certificate lines in the old nginx volume. If the volume is the system CA store, it copies only those two files into ./certs/.
UI: https://alerts.example.com/tobealert · API used by the browser: https://alerts.example.com:9000/api/…
1. Run this on the server
Paste the whole block. Read the lines Using certificate / Using key before you change Compose. If it stops, it could not uniquely identify the files — that is intentional.
#!/usr/bin/env bash
# ToBeAlert HTTPS helper. Run next to docker-compose.yaml.
# Does NOT pick "the first .pem" (that is usually a CA). It looks for:
# 1) ssl_certificate / ssl_certificate_key in the old nginx volume
# 2) a private key (*.key) — CA stores almost never have one
# 3) a cert with the same basename as that key
# If the volume looks like /etc/ssl/certs (many PEMs), it COPIES the two
# files into ./certs/ instead of mounting the whole store.
set -euo pipefail
HOST='alerts.example.com'
INSTALL_PATH='.'
REUSE_VOLUME='1'
cd "$INSTALL_PATH"
echo "Working directory: $(pwd)"
echo "Hostname: $HOST"
echo
pick_vol() {
local suffix="$1"
docker volume ls -q | grep -E "^${suffix}$" | head -n1 && return 0
docker volume ls -q | grep -E "_${suffix}$" | head -n1 || true
}
CERT_VOL="$(pick_vol certificate_data || true)"
NGINX_VOL="$(pick_vol nginx_data || true)"
echo "== Docker volumes =="
docker volume ls
echo "certificate volume: ${CERT_VOL:-<none>}"
echo "nginx volume: ${NGINX_VOL:-<none>}"
echo
CERT_PEM=""
CERT_KEY=""
if [ -n "$NGINX_VOL" ]; then
echo "== ssl_certificate lines in old nginx volume (ignore default.conf if it only has listen 8080) =="
docker run --rm -v "$NGINX_VOL":/conf:ro alpine \
sh -c 'grep -rE "ssl_certificate" /conf 2>/dev/null || true'
echo
OLD_CERT="$(docker run --rm -v "$NGINX_VOL":/conf:ro alpine \
sh -c 'grep -rE "ssl_certificate " /conf 2>/dev/null | grep -v ssl_certificate_key | head -n1' | awk '{print $NF}' | tr -d ';' || true)"
OLD_KEY="$(docker run --rm -v "$NGINX_VOL":/conf:ro alpine \
sh -c 'grep -rE "ssl_certificate_key" /conf 2>/dev/null | head -n1' | awk '{print $NF}' | tr -d ';' || true)"
if [ -n "$OLD_CERT" ]; then CERT_PEM="$(basename "$OLD_CERT")"; fi
if [ -n "$OLD_KEY" ]; then CERT_KEY="$(basename "$OLD_KEY")"; fi
echo "From nginx config: cert=${CERT_PEM:-?} key=${CERT_KEY:-?}"
echo
fi
FILE_COUNT=0
if [ "$REUSE_VOLUME" = "1" ] && [ -n "$CERT_VOL" ]; then
echo "== Files in $CERT_VOL (first 40) =="
docker run --rm -v "$CERT_VOL":/certs:ro alpine ls -la /certs | head -n 40
echo
FILES="$(docker run --rm -v "$CERT_VOL":/certs:ro alpine \
sh -c 'find /certs -maxdepth 1 -type f ! -name ".*" | sed "s|.*/||"')"
FILE_COUNT="$(printf '%s\n' "$FILES" | grep -c . || true)"
PEM_COUNT="$(printf '%s\n' "$FILES" | grep -Eic '\.(pem|crt|cer)$' || true)"
echo "files=$FILE_COUNT pem/crt=$PEM_COUNT"
if [ "$PEM_COUNT" -gt 8 ]; then
echo
echo "NOTE: this volume looks like the system CA store (/etc/ssl/certs),"
echo "not a folder with only your server certificate. Do NOT use the first .pem."
echo "A private key (*.key) is the file that identifies YOUR cert."
echo
fi
if [ -z "$CERT_KEY" ]; then
CERT_KEY="$(printf '%s\n' "$FILES" | grep -Ei '\.key$' | head -n1 || true)"
fi
if [ -n "$CERT_KEY" ] && [ -z "$CERT_PEM" ]; then
BASE="${CERT_KEY%.*}"
CERT_PEM="$(printf '%s\n' "$FILES" | grep -E "^${BASE}\.(pem|crt|cer)$" | head -n1 || true)"
if [ -z "$CERT_PEM" ]; then
CERT_PEM="$(printf '%s\n' "$FILES" | grep -Ei "^${BASE}" | grep -Eiv '\.key$' | head -n1 || true)"
fi
fi
fi
if [ -z "$CERT_PEM" ] || [ -z "$CERT_KEY" ]; then
if [ -f certs/fullchain.pem ] && [ -f certs/privkey.pem ]; then
CERT_PEM="fullchain.pem"
CERT_KEY="privkey.pem"
CERT_VOL=""
echo "Using ./certs/fullchain.pem and ./certs/privkey.pem"
fi
fi
if [ -z "$CERT_PEM" ] || [ -z "$CERT_KEY" ]; then
echo
echo "STOPPED: could not uniquely identify certificate + key."
echo "Look for a *.key (private key). Then a *.pem / *.crt with the same name."
echo " docker run --rm -v ${CERT_VOL:-certificate_data}:/certs:ro alpine ls -la /certs"
echo "Copy those TWO files to ./certs/ and re-run, or fill the filenames in the docs wizard."
exit 1
fi
echo
echo "Using certificate: $CERT_PEM"
echo "Using key: $CERT_KEY"
echo "Confirm these names match YOUR hostname ($HOST), not a CA like ISRG/DST/GlobalSign."
echo
CERT_MOUNT="./certs:/certs:ro"
if [ "$REUSE_VOLUME" = "1" ] && [ -n "$CERT_VOL" ]; then
if [ "$PEM_COUNT" -gt 8 ]; then
echo "Copying only those two files out of the CA store into ./certs/"
mkdir -p certs
docker run --rm -v "$CERT_VOL":/from:ro -v "$(pwd)/certs":/to alpine \
sh -c "cp /from/$CERT_PEM /from/$CERT_KEY /to/ && ls -la /to"
CERT_VOL=""
else
CERT_MOUNT="$CERT_VOL:/certs:ro"
fi
fi
cat > https.conf <<EOF
server {
listen 443 ssl;
server_name $HOST;
ssl_certificate /certs/$CERT_PEM;
ssl_certificate_key /certs/$CERT_KEY;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://tobealert-frontend: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 Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
}
}
EOF
echo "Wrote $(pwd)/https.conf"
echo
echo "======== Paste into docker-compose.yaml ========"
cat <<YAML
tobealert-frontend:
environment:
BASE_URL: https://$HOST
API_URL: https://$HOST:9000
BACKEND_SERVICE: tobealert-backend
BACKEND_PORT: "9000"
ports:
- "80:8080"
# REMOVE: "443:443"
# REMOVE frontend volumes: nginx_data and certificate_data
tobealert-backend:
environment:
SSL_CERT: /certs/$CERT_PEM
SSL_KEY: /certs/$CERT_KEY
volumes:
- $CERT_MOUNT
tobealert-https:
image: nginx:stable-alpine
container_name: tobealert-https
ports:
- "443:443"
volumes:
- ./https.conf:/etc/nginx/conf.d/default.conf:ro
- $CERT_MOUNT
depends_on:
- tobealert-frontend
restart: unless-stopped
networks:
- tobealert-network
YAML
echo "================================================"
echo
echo "Then: docker compose up -d"
echo
echo "Checks (expected):"
echo " curl -skI https://$HOST/api"
echo " -> 301 Location: /tobealert (NOT the API; missing trailing slash)"
echo " curl -sk https://$HOST/api/management/healthcheck"
echo " -> JSON with status (frontend nginx proxies /api/ to backend)"
echo " curl -sk https://$HOST:9000/api/management/healthcheck"
echo " -> JSON with status (this is what the Web UI uses)"
echo " curl -skI https://$HOST/tobealert/"
echo " -> 200"
2. Compose fragment (preview)
Prefer the YAML the script prints (it has the real filenames). Fill the optional filename fields above only if you already know them.
tobealert-frontend:
environment:
BASE_URL: https://alerts.example.com
API_URL: https://alerts.example.com:9000
BACKEND_SERVICE: tobealert-backend
BACKEND_PORT: "9000"
ports:
- "80:8080"
# REMOVE "443:443"
# REMOVE volumes nginx_data and certificate_data from the frontend
tobealert-backend:
environment:
SSL_CERT: /certs/YOUR_CERT.pem
SSL_KEY: /certs/YOUR_KEY.key
volumes:
- certificate_data:/certs:ro
tobealert-https:
image: nginx:stable-alpine
container_name: tobealert-https
ports:
- "443:443"
volumes:
- ./https.conf:/etc/nginx/conf.d/default.conf:ro
- certificate_data:/certs:ro
depends_on:
- tobealert-frontend
restart: unless-stopped
networks:
- tobealert-network
After the script:
- Read
Using certificate/Using key. If those names are CAs, stop and pick the.keyby hand (section above). - Paste the YAML fragment into
docker-compose.yaml(keep your existing passwords andDATABASE_URL). - Remove from the frontend:
"443:443",nginx_data,certificate_data. - Frontend ports must be
"80:8080"(not"80:80"). - Add
networks.tobealert-networkat the bottom if it is missing. docker compose up -d— without-v.
New certificates (no old volume)
Skip this if you reused certificate_data / ./certs from the script.
export INSTALL_PATH=/opt/tobealert # your compose directory
mkdir -p "$INSTALL_PATH/certs"
# copy the CA files to:
# $INSTALL_PATH/certs/fullchain.pem (certificate + chain)
# $INSTALL_PATH/certs/privkey.pem (private key)
chmod 640 "$INSTALL_PATH/certs/privkey.pem"
If the CA gave you cert.pem and chain.pem:
cat cert.pem chain.pem > "$INSTALL_PATH/certs/fullchain.pem"
https.conf next to docker-compose.yaml (replace the hostname and filenames):
server {
listen 443 ssl;
server_name alerts.example.com;
ssl_certificate /certs/fullchain.pem;
ssl_certificate_key /certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://tobealert-frontend: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 Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
Compose: the three edits
Keep PostgreSQL, Redis, and your existing secrets. Only these services change.
Frontend
tobealert-frontend:
image: tobek8s/tobealert-frontend:latest
container_name: tobealert-frontend
environment:
BASE_URL: https://alerts.example.com
API_URL: https://alerts.example.com:9000
BACKEND_SERVICE: tobealert-backend
BACKEND_PORT: "9000"
ports:
- "80:8080"
depends_on:
- tobealert-backend
restart: unless-stopped
networks:
- tobealert-network
No 443. No nginx_data. No certificate_data.
Backend
Add SSL_CERT, SSL_KEY, REDIS_PORT, and the cert mount. Do not replace ADMIN_* or DATABASE_URL. Escape # in the password as %23.
REDIS_PORT: "6379"
SSL_CERT: /certs/fullchain.pem
SSL_KEY: /certs/privkey.pem
volumes:
- ./certs:/certs:ro
(Or certificate_data:/certs:ro when the script told you to keep the volume and it is not a CA dump.)
New service
tobealert-https:
image: nginx:stable-alpine
container_name: tobealert-https
ports:
- "443:443"
volumes:
- ./https.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/certs:ro
depends_on:
- tobealert-frontend
restart: unless-stopped
networks:
- tobealert-network
Bottom of the file:
networks:
tobealert-network:
driver: bridge
Five services: PostgreSQL, Redis, backend, frontend, tobealert-https.
docker compose up -d
Verify — in this order
Replace alerts.example.com with your hostname. Do not use 127.0.0.1 for HTTPS.
1. Frontend nginx still listens on 8080
docker exec tobealert-frontend grep listen /etc/nginx/conf.d/default.conf
docker exec tobealert-frontend grep proxy_pass /etc/nginx/conf.d/default.conf
Expected: listen 8080; and proxy_pass http://tobealert-backend:9000; (the name must be substituted, not the string BACKEND_SERVICE).
2. /api without a path is not the API
curl -skI https://alerts.example.com/api
Expected: 301 and Location: /tobealert. If you got this and thought the API was missing, that is nginx doing its job.
3. Frontend proxy to the backend (/api/ with slash)
curl -sk https://alerts.example.com/api/management/healthcheck
Expected: JSON containing "status". This path is TLS proxy → frontend → backend.
4. API the Web UI actually uses (:9000)
curl -sk https://alerts.example.com:9000/api/management/healthcheck
Expected: the same JSON. If this fails and step 3 works, SSL_CERT / SSL_KEY on the backend are wrong or missing.
5. UI
curl -skI https://alerts.example.com/tobealert/
Expected: 200. Open https://alerts.example.com/tobealert in a browser.
6. Process list
docker compose ps
Expected: tobealert-https up, 0.0.0.0:443->443/tcp. Frontend shows 0.0.0.0:80->8080/tcp and must not show 443->443.
If something fails
SSL_ERROR_SYSCALL on port 443
Port 443 is mapped to the frontend, or nothing is speaking TLS there.
docker compose ps
docker exec tobealert-https nginx -t
docker logs --tail=50 tobealert-https
Fix: "443:443" only on tobealert-https. Recreate: docker compose up -d --force-recreate tobealert-https.
Browser says the certificate is wrong / “not private”
https.conf points at a CA .pem from /etc/ssl/certs, not the server cert.
Fix: find the *.key, pair it with the matching .pem, put those two in ./certs/, update ssl_certificate / SSL_CERT, recreate proxy and backend.
UI loads, login or data fails, browser console mixed content or 404 on /backend/api
API_URL is still http://… or ends with /backend or /api.
Fix: API_URL: https://YOUR_HOST:9000 with no path. Recreate the frontend (config.json is generated at start).
docker exec tobealert-frontend cat /usr/share/nginx/html/tobealert/config.json | head
Expected: "apiUrl": "https://YOUR_HOST:9000/api/".
/api in the browser “does not exist”
You requested /api. That is a redirect to the UI. Use /api/management/healthcheck or :9000/api/management/healthcheck.
Step 3 works, step 4 does not (or the opposite)
Step 3 (https://host/api/…) | Step 4 (https://host:9000/api/…) | Meaning |
|---|---|---|
| OK | Fail | Backend TLS (SSL_CERT/SSL_KEY) missing or wrong. The UI will fail. |
| Fail | OK | Frontend cannot reach tobealert-backend:9000 (wrong network, or proxy_pass still has ${BACKEND_SERVICE}). UI can still work if API_URL uses :9000. |
| Fail | Fail | Backend down, or # in DATABASE_URL. |
docker exec tobealert-backend wget -qO- http://127.0.0.1:9000/api/management/healthcheck
docker compose logs --tail=80 tobealert-backend
Inside the backend container the healthcheck is HTTP on localhost even when the published 9000 is HTTPS.
tobealert-https restart loop / cannot load certificate
Filenames in https.conf do not exist in /certs.
docker exec tobealert-https ls -la /certs
docker exec tobealert-https cat /etc/nginx/conf.d/default.conf
The names in ssl_certificate must match ls.
HTTP on port 80 works, HTTPS does not, frontend maps 80:80
Current image listens on 8080. Use "80:8080".
Backend restart loop, Postgres is healthy
Unescaped # in DATABASE_URL. Replace # with %23.
Older versions vs current (why an extra container)
| Older frontend (1.2.23 line) | Current frontend | |
|---|---|---|
| Listen | 80 and 443 inside the frontend | 8080 only |
| User | Could bind 443 | Non-root; cannot bind 443 |
| Typical Compose | "80:80", "443:443", certificate_data:/etc/ssl/certs | "80:8080", TLS on tobealert-https |
| Cert volume | Used by frontend nginx | Reused by the proxy (or copied to ./certs/ if it is a CA dump) |
Mounting the old volumes on a current frontend does not restore HTTPS. The extra container replaces the TLS that used to live in the frontend.
Settings reference: Settings & environment variables.