Skip to main content
Version: 1.4 (Current)

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 typeWho answersWhat it is for
https://host/tobealert/TLS proxy → frontendThe Web UI. This is what people open.
https://host:9000/api/management/healthcheckBackend TLSThe API the UI uses. API_URL must be https://host:9000 with no path.
https://host/api/management/healthcheckTLS proxy → frontend nginx location /api/ → backendExtra 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 thisWhat you will see
"443:443" on tobealert-frontendcurl https:// → SSL_ERROR_SYSCALL. Nothing inside that container listens on 443.
Leave nginx_data:/etc/nginx/conf.d on the frontendEvery start overwrites default.conf with listen 8080 only. Old listen 443 ssl is gone.
Leave certificate_data:/etc/ssl/certs on the frontendThat 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/backendBrowser calls /backend/api/…. There is no /backend location. API “does not exist”.
API_URL: https://host/apiconfig.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 -vDeletes certificate_data. The old certificates are gone.
Opening https://host/api in the browserRedirect to /tobealert. This is not a backend 404.
Calling https://127.0.0.1/Certificate name mismatch. Use the DNS name.
# in DATABASE_URL unescapedBackend 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 301 or 200 involving /tobealert.
  • listen 8080; inside the frontend. If you still see listen 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*.pem lines — that is the private key.
  • The certificate is the .pem / .crt with the same basename, or the path in ssl_certificate (not ssl_certificate_key).
  • Names such as ISRG, DST, GlobalSign, ca-certificates, ca-bundle are 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.

discover-https.sh
#!/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.

docker-compose.yaml (fragment)
  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:

  1. Read Using certificate / Using key. If those names are CAs, stop and pick the .key by hand (section above).
  2. Paste the YAML fragment into docker-compose.yaml (keep your existing passwords and DATABASE_URL).
  3. Remove from the frontend: "443:443", nginx_data, certificate_data.
  4. Frontend ports must be "80:8080" (not "80:80").
  5. Add networks.tobealert-network at the bottom if it is missing.
  6. 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
OKFailBackend TLS (SSL_CERT/SSL_KEY) missing or wrong. The UI will fail.
FailOKFrontend cannot reach tobealert-backend:9000 (wrong network, or proxy_pass still has ${BACKEND_SERVICE}). UI can still work if API_URL uses :9000.
FailFailBackend 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
Listen80 and 443 inside the frontend8080 only
UserCould bind 443Non-root; cannot bind 443
Typical Compose"80:80", "443:443", certificate_data:/etc/ssl/certs"80:8080", TLS on tobealert-https
Cert volumeUsed by frontend nginxReused 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.