Docker: automatic SSL with a self-signed certificate, reused if present

docker-entrypoint.sh uses the given SSL_CERTFILE/SSL_KEYFILE, or the
certificate in the electrumx-ssl volume, and generates a self-signed one
(10 years) only when none exists, never overwriting it, as clients pin it.
It also sets SERVICES (tcp 50001 + ssl 50002). SSL is on by default.

Documented in README.md and DEPLOY.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-22 15:23:29 +02:00
1 parent 3e5d005efe
commit c2dcff5cc8
7 files changed
+173 -29

No files matched your search

+9
View File
@@ -5,3 +5,12 @@ PLM_RPC_PASSWORD=change-me
#CACHE_MB=1200
#REPORT_SERVICES=tcp://electrum.example.com:50001
#ELECTRUMX_TCP_PORT=50001
# SSL (port 50002): on by default, self-signed certificate generated once and kept in the electrumx-ssl volume.
#SSL_ENABLED=true
# name (or IP) of the server, written in the generated certificate
#SSL_HOSTNAME=electrum.example.com
#ELECTRUMX_SSL_PORT=50002
# your own certificate (paths inside the container; mount the files, see DEPLOY.md):
#SSL_CERTFILE=/certs/fullchain.pem
#SSL_KEYFILE=/certs/privkey.pem
+1 -1
View File
@@ -22,7 +22,7 @@ DB_ENGINE=rocksdb DB_DIRECTORY=... DAEMON_URL=http://user:pass@host:port/ electr
electrumx_rpc getinfo # talk to a running server via local RPC
```
Docker: `docker compose up -d --build` (root `Dockerfile`, `docker-compose.yml`, `.env` from `.env.example`) runs the server on the external `palladium` network of the Palladium node container, daemon at `palladiumd:2332`.
Docker: `docker compose up -d --build` (root `Dockerfile`, `docker-compose.yml`, `.env` from `.env.example`) runs the server on the external `palladium` network of the Palladium node container, daemon at `palladiumd:2332`. `docker-entrypoint.sh` sets up SSL: it uses `SSL_CERTFILE`/`SSL_KEYFILE` if given, else the self-signed certificate in the `electrumx-ssl` volume, generating it only if missing (never overwrite it: clients pin it), and sets `SERVICES`.
Configuration is entirely via environment variables, parsed in `src/electrumx/server/env.py` (documented in `docs/environment.rst`). `DB_ENGINE` is mandatory in 2.0 (`leveldb` or `rocksdb`). Python >=3.10; free-threaded 3.14t is experimental.
+76 -20
View File
@@ -47,12 +47,12 @@ Keep `.env` private (`chmod 600 .env`); it is ignored by git.
| Port | What | Open to | Notes |
| --- | --- | --- | --- |
| **50001/tcp** | Electrum protocol, plain | clients (internet, if the server is public) | published by `docker-compose.yml` |
| **50002/tcp** | Electrum protocol over SSL | clients | optional, needs a certificate (see below); not published by default |
| **50002/tcp** | Electrum protocol over SSL | clients | on by default, certificate generated automatically (see "SSL"); published by `docker-compose.yml` |
| **2333/tcp** | Palladium P2P (the node) | the internet, inbound | published by the node's compose file |
| 2332/tcp | node RPC | **only the `palladium` network** | never publish or forward it: it can spend wallet funds |
| 28332, 28334, 28335/tcp | node ZMQ | only the `palladium` network | never publish or forward them |
Changing the published port: `ELECTRUMX_TCP_PORT` in `.env` (host side only; the container always listens on 50001).
Changing the published ports: `ELECTRUMX_TCP_PORT` / `ELECTRUMX_SSL_PORT` in `.env` (host side only; the container always listens on 50001).
A server only for yourself (or for other containers) needs no open port: connect to `127.0.0.1:50001`, or join the
`palladium` network and use `electrumx:50001`.
@@ -79,31 +79,85 @@ Never forward 2332 or 28332/28334/28335. A server on a VPS with a public IP open
## SSL (port 50002)
Most Electrum wallets prefer SSL. You need a certificate and key for the host name of the server.
SSL is **on by default** and needs nothing to be set up: Electrum wallets prefer it, and plain TCP (50001) sends
addresses and balances unencrypted.
1. Get a certificate, for example with Let's Encrypt (`certbot certonly --standalone -d electrum.example.com`),
or a self-signed one for tests:
```bash
openssl req -x509 -newkey rsa:4096 -nodes -days 3650 -subj "/CN=electrum.example.com" \
-keyout privkey.pem -out fullchain.pem
```
2. Create `docker-compose.override.yml` next to `docker-compose.yml` (compose reads it automatically):
### What happens at start
`docker-entrypoint.sh` (the entrypoint of the image) does this at every start:
1. If you gave your own certificate (`SSL_CERTFILE` and `SSL_KEYFILE`, see below), it uses that.
2. Otherwise it looks for `/var/lib/electrumx-ssl/server.crt` and `server.key`, in the `electrumx-ssl` volume.
- **They exist: they are used as they are, never overwritten.**
- **They do not exist (first start): a self-signed certificate valid 10 years is generated** for `SSL_HOSTNAME`
and saved there. If only one of the two files exists, the container stops with an error instead of overwriting it.
3. It prints the certificate subject, expiry date and SHA-256 fingerprint in the log
(`docker compose logs electrumx | grep entrypoint`), and warns if it expires in less than 30 days.
4. If `SERVICES` is not set, it sets it to `tcp://:50001,ssl://:50002`.
The compose file publishes 50002 (`ELECTRUMX_SSL_PORT`). Settings, all in `.env`:
| Variable | Default | Meaning |
| --- | --- | --- |
| `SSL_ENABLED` | `true` | `false`: no SSL, only `tcp://:50001` |
| `SSL_HOSTNAME` | `electrumx` | name (or IP) written in a generated certificate; set it to the name of the server (it matters only when the certificate is generated) |
| `SSL_CERTFILE`, `SSL_KEYFILE` | unset | your own certificate and key (paths inside the container); both or neither |
| `ELECTRUMX_SSL_PORT` | `50002` | host port published for SSL |
### Self-signed certificate: what clients see
A generated certificate is signed by nobody. Electrum wallets handle it by *trust on first use*: at the first connection
the wallet saves the certificate, and from then on it refuses a different one (as SSH does with host keys), so a
man-in-the-middle cannot take over later. The fields of the certificate (name, country, ...) are not checked by the
client, only the expiry date. Consequences:
- **Keep the certificate.** It is in the `electrumx-ssl` volume, separate from the database on purpose: the database
can be deleted and rebuilt at any time (`docker volume rm <project>_electrumx-db`), the certificate should not be.
`docker compose down -v` deletes **both** volumes, and after that a new certificate is generated and every client
that saved the old one refuses to connect.
- **Back it up** while the container runs:
```bash
docker compose cp electrumx:/var/lib/electrumx-ssl ./ssl-backup # contains the private key: keep it private
```
To restore it, put the two files somewhere on the host and use them as "your own certificate" below.
- If it expires or is lost, clients that saved it must be told to forget it, or the server must get a new name.
Generated certificates last 10 years, so expiry is rarely the problem.
- Whether a wallet accepts a self-signed certificate depends on the wallet. Electrum does (trust on first use);
check the wallet you intend to use.
### Your own certificate (for example Let's Encrypt)
With a certificate signed by a CA the client verifies it by itself, with no trust on first use, but it needs a domain
and must be renewed (about every 90 days).
1. Get the certificate and key (`certbot certonly --standalone -d electrum.example.com`).
Copy the files out of `/etc/letsencrypt/live/...` (they are symlinks into `archive/`) to a folder, and copy them
again after each renewal. The container runs as uid 1000, so the key must be readable by it
(`chown 1000 privkey.pem && chmod 600 privkey.pem`).
2. Mount the folder with a `docker-compose.override.yml` next to `docker-compose.yml` (compose reads it automatically):
```yaml
services:
electrumx:
environment:
SERVICES: tcp://:50001,ssl://:50002
SSL_CERTFILE: /certs/fullchain.pem
SSL_KEYFILE: /certs/privkey.pem
ports:
- "50002:50002"
volumes:
- /path/to/certs:/certs:ro
```
The container runs as uid 1000: the key must be readable by it (`chown 1000 privkey.pem; chmod 600 privkey.pem`).
With Let's Encrypt, copy the files out of `/etc/letsencrypt/live/...` (they are symlinks into `archive/`), and
copy them again after each renewal.
3. `docker compose up -d`, and open 50002 as in "Ports".
3. In `.env`:
```ini
SSL_CERTFILE=/certs/fullchain.pem
SSL_KEYFILE=/certs/privkey.pem
```
4. `docker compose up -d`. After a renewal, `docker compose restart electrumx` loads the new files.
If the files cannot be read, the container stops with a message that says so (see `docker compose logs electrumx`).
### Check it
```bash
echo | openssl s_client -connect 127.0.0.1:50002 2>/dev/null | openssl x509 -noout -subject -enddate -fingerprint -sha256
printf '{"id":1,"method":"server.version","params":["test","1.4"]}\n' | openssl s_client -quiet -connect 127.0.0.1:50002
```
The fingerprint must be the one printed in the log. Open 50002 as in "Ports" for a public server.
## Announcing a public server
@@ -160,3 +214,5 @@ From another machine, `nc -zv <public IP> 50001` must succeed if the server is m
| `blockchain.outpoint.subscribe` or package broadcast return a daemon error | expected: Palladium Core is based on Bitcoin Core 0.19 and does not have these RPCs |
| the server stays at an old height | the node is not synced; ElectrumX only follows the node |
| clients cannot connect from outside | port not published/forwarded, firewall, or `SERVICES` does not list that protocol |
| container restarts at once, log says `ERROR: ... SSL_CERTFILE` | your own certificate is not mounted or not readable by uid 1000, only one of `SSL_CERTFILE`/`SSL_KEYFILE` is set, or only one of the generated files exists in the `electrumx-ssl` volume |
| a wallet refuses the server after it worked | the certificate changed (for example after `docker compose down -v`); the wallet saved the old one |
+11 -7
View File
@@ -24,14 +24,14 @@ RUN python -m venv venv \
FROM python:3.14-slim-trixie
# the libs needed by rocksdb (no development headers or statics)
RUN apt-get update && apt-get -y --no-install-recommends install librocksdb9.10 && rm -rf /var/lib/apt/lists/*
# the libs needed by rocksdb (no development headers or statics), and openssl to generate the SSL certificate
RUN apt-get update && apt-get -y --no-install-recommends install librocksdb9.10 openssl && rm -rf /var/lib/apt/lists/*
ENV COIN=Palladium
ENV NET=mainnet
ENV DB_ENGINE=rocksdb
ENV DB_DIRECTORY=/var/lib/electrumx
ENV SERVICES="tcp://:50001"
# SERVICES is set by docker-entrypoint.sh (tcp://:50001 and, with SSL, ssl://:50002), see there for the SSL settings.
# set at run time, see docker-compose.yml:
#ENV DAEMON_URL="http://user:password@palladiumd:2332/"
#ENV CACHE_MB=2000
@@ -39,14 +39,18 @@ ENV SERVICES="tcp://:50001"
WORKDIR /usr/src/app
COPY --from=builder /usr/src/app/venv ./venv
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# the data directory is created here so that an empty volume is owned by the electrumx user
# the data directories are created here so that empty volumes are owned by the electrumx user
RUN useradd --create-home --uid 1000 electrumx \
&& mkdir -p "$DB_DIRECTORY" && chown electrumx:electrumx "$DB_DIRECTORY"
&& mkdir -p "$DB_DIRECTORY" /var/lib/electrumx-ssl \
&& chown electrumx:electrumx "$DB_DIRECTORY" /var/lib/electrumx-ssl
USER electrumx
VOLUME /var/lib/electrumx
VOLUME /var/lib/electrumx-ssl
# 50001: Electrum protocol over TCP.
EXPOSE 50001
# 50001: Electrum protocol over TCP, 50002: over SSL.
EXPOSE 50001 50002
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["/usr/src/app/venv/bin/electrumx_server"]
+3 -1
View File
@@ -28,7 +28,9 @@ as `palladiumd:2332`. RPC and ZMQ of the node are not published on the host, so
The first start builds the database from the node from genesis, which takes a while; follow it with
`docker compose logs -f electrumx`. The database is the `electrumx-db` volume. Electrum clients connect to
TCP port 50001 (`ELECTRUMX_TCP_PORT` in `.env`). To announce a public server to the network, set
TCP port 50001 (`ELECTRUMX_TCP_PORT` in `.env`) and SSL port 50002 (`ELECTRUMX_SSL_PORT`). SSL is on by default:
a self-signed certificate is generated on the first start, kept in the `electrumx-ssl` volume and reused on every
later start (or use your own, see [DEPLOY.md](DEPLOY.md)). To announce a public server to the network, set
`REPORT_SERVICES` in `.env`. Settings are in `.env` and `docker-compose.yml`; the image defaults are in the
`Dockerfile`. Ports, firewall, SSL, RPC credentials and troubleshooting are in [DEPLOY.md](DEPLOY.md).
+9
View File
@@ -11,15 +11,24 @@ services:
CACHE_MB: ${CACHE_MB:-1200}
# address announced to other servers and clients, if this server is public (tcp://host:50001)
REPORT_SERVICES: ${REPORT_SERVICES:-}
# SSL: on by default, with a self-signed certificate generated on the first start (docker-entrypoint.sh)
SSL_ENABLED: ${SSL_ENABLED:-true}
SSL_HOSTNAME: ${SSL_HOSTNAME:-electrumx}
# your own certificate instead (paths inside the container, mount the files with an override file)
SSL_CERTFILE: ${SSL_CERTFILE:-}
SSL_KEYFILE: ${SSL_KEYFILE:-}
ports:
- "${ELECTRUMX_TCP_PORT:-50001}:50001"
- "${ELECTRUMX_SSL_PORT:-50002}:50002"
volumes:
- electrumx-db:/var/lib/electrumx
- electrumx-ssl:/var/lib/electrumx-ssl # the certificate: keep it, clients pin it
stop_grace_period: 2m # lets the server flush the db on shutdown
networks: [palladium]
volumes:
electrumx-db:
electrumx-ssl:
networks:
palladium:
+64
View File
@@ -0,0 +1,64 @@
#!/bin/sh
# Entrypoint of the plm-electrumx image: sets up SSL, then runs the server (the command given to the container).
#
# SSL_ENABLED true (default) / false. If true, the server also listens on ssl://:50002.
# SSL_HOSTNAME name (or IP) written in a generated certificate. Default: electrumx.
# SSL_CERTFILE, SSL_KEYFILE
# paths of your own certificate and key (mounted in the container). If unset, the files
# /var/lib/electrumx-ssl/server.crt and server.key are used; they are generated on the first
# start (self-signed, 10 years) and reused on every later start, never overwritten.
# SERVICES if unset: tcp://:50001, plus ssl://:50002 when SSL is enabled. If set, it is left as it is.
set -eu
SSL_DIR=/var/lib/electrumx-ssl
SSL_ENABLED="${SSL_ENABLED:-true}"
SSL_HOSTNAME="${SSL_HOSTNAME:-electrumx}"
log() { echo "entrypoint: $*"; }
case "$SSL_ENABLED" in
true|TRUE|True|1|yes|on)
if [ -n "${SSL_CERTFILE:-}" ] || [ -n "${SSL_KEYFILE:-}" ]; then
if [ -z "${SSL_CERTFILE:-}" ] || [ -z "${SSL_KEYFILE:-}" ]; then
log "ERROR: set both SSL_CERTFILE and SSL_KEYFILE, or neither" >&2; exit 1
fi
if [ ! -r "$SSL_CERTFILE" ] || [ ! -r "$SSL_KEYFILE" ]; then
log "ERROR: cannot read $SSL_CERTFILE / $SSL_KEYFILE (is it mounted, and readable by uid $(id -u)?)" >&2; exit 1
fi
log "using the SSL certificate $SSL_CERTFILE"
else
export SSL_CERTFILE="$SSL_DIR/server.crt"
export SSL_KEYFILE="$SSL_DIR/server.key"
if [ -e "$SSL_CERTFILE" ] && [ -e "$SSL_KEYFILE" ]; then
log "using the existing SSL certificate $SSL_CERTFILE"
elif [ -e "$SSL_CERTFILE" ] || [ -e "$SSL_KEYFILE" ]; then
log "ERROR: only one of $SSL_CERTFILE and $SSL_KEYFILE exists; not overwriting it. Fix or remove it." >&2; exit 1
else
case "$SSL_HOSTNAME" in
*[!0-9.]*) san="DNS:$SSL_HOSTNAME" ;;
*) san="IP:$SSL_HOSTNAME" ;;
esac
log "generating a self-signed SSL certificate for $SSL_HOSTNAME (valid 10 years) in $SSL_DIR"
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-subj "/CN=$SSL_HOSTNAME" -addext "subjectAltName=$san" \
-keyout "$SSL_KEYFILE" -out "$SSL_CERTFILE" 2>/dev/null
chmod 600 "$SSL_KEYFILE"
fi
fi
log "certificate: $(openssl x509 -in "$SSL_CERTFILE" -noout -subject -enddate | tr '\n' ' ')"
log "$(openssl x509 -in "$SSL_CERTFILE" -noout -fingerprint -sha256)"
if ! openssl x509 -in "$SSL_CERTFILE" -noout -checkend 2592000 >/dev/null; then
log "WARNING: the SSL certificate expires in less than 30 days or is already expired"
fi
export SERVICES="${SERVICES:-tcp://:50001,ssl://:50002}"
;;
*)
# empty values would be read by the server as "set"
unset SSL_CERTFILE SSL_KEYFILE 2>/dev/null || true
export SERVICES="${SERVICES:-tcp://:50001}"
log "SSL disabled"
;;
esac
log "SERVICES=$SERVICES"
exec "$@"