Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
.git/
.github/
.venv/
__pycache__/
*.pyc
*.egg-info/
.pytest_cache/

# secrets and per-server settings are mounted at run time, never baked into the image
secrets.yaml
config.yaml
api_key
slack_bot_token

# generated output
static/print.pdf

# not needed at run time
tests/
client/
deploy/
52 changes: 52 additions & 0 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Docker image

# Proves on every pull request and push to main that the image builds, the
# offline tests pass inside it, and it serves requests. Nothing is published:
# the lab server builds its own image from its checkout (deploy/compose.yaml).
on:
push:
branches: [main]
pull_request:
workflow_dispatch: # adds a "Run workflow" button in the Actions tab

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Build
run: docker build -t eln-server-ci .

- name: Dummy config for the tests and smoke test (no real ELN or Slack)
run: |
cp config-ex.yaml config.yaml
sed -i 's|^eln_url:.*|eln_url: https://eln.invalid/api/v2|; s|^eln_web_url:.*|eln_web_url: https://eln.invalid|; s|^slack_enabled:.*|slack_enabled: false|' config.yaml
cp secrets.example.yaml secrets.yaml

- name: Offline tests inside the image
run: |
docker run --rm --user root -e PYTHONDONTWRITEBYTECODE=1 -v "$PWD":/src -w /src eln-server-ci \
sh -c 'pip install -q pytest && python -m pytest -m "not live" -p no:cacheprovider'

- name: Smoke test the image
run: |
docker run -d --name eln -p 5000:5000 \
-v "$PWD/config.yaml":/app/config.yaml -v "$PWD/secrets.yaml":/app/secrets.yaml:ro \
eln-server-ci
for i in $(seq 30); do curl -fsS http://127.0.0.1:5000/ping && break || sleep 1; done
curl -fsS -o /dev/null http://127.0.0.1:5000/
# label PDFs need Pango and fonts in the image: check one renders its text in DejaVu
curl -fsS -X POST -H 'Content-Type: application/json' -o label.pdf \
-d '{"Title":"CI","Text":"smoke test","Icon":"None","QRContentType":"Resource","QRContent":1,"Height":18}' \
http://127.0.0.1:5000/create_label
docker run --rm -v "$PWD/label.pdf":/tmp/label.pdf:ro eln-server-ci python -c "
from pypdf import PdfReader
page = PdfReader('/tmp/label.pdf').pages[0]
fonts = [str(f.get_object()['/BaseFont']) for f in page['/Resources']['/Font'].values()]
print(fonts)
assert 'smoke test' in page.extract_text()
assert any('DejaVu' in f for f in fonts)
"
docker logs eln
docker rm -f eln
41 changes: 41 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# eln_server (Flask + gunicorn). Build context: the repository root.
# config.yaml and secrets.yaml are not baked in: mount them at run time (see deploy/compose.yaml).
FROM python:3.11-slim-bookworm

# system libraries the Python packages load at run time:
# WeasyPrint (label PDFs): Pango + DejaVu fonts (labels ask for Courier New -> DejaVu Sans Mono)
# RDKit (structure images): libXrender, libXext
# tzdata: "Opened" dates are stamped in lab time (TZ below)
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libpango-1.0-0 libpangoft2-1.0-0 fonts-dejavu-core libxrender1 libxext6 tzdata \
&& rm -rf /var/lib/apt/lists/*

ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
TZ=America/New_York

WORKDIR /app

# dependencies first: this layer is cached and only rebuilt when they change.
# constraints.txt pins the versions the test suite was run against.
COPY requirements.txt constraints.txt ./
RUN pip install -r requirements.txt -c constraints.txt

# then the application code
COPY . .

# run as a non-root user with uid 1000, so the mounted config.yaml/secrets.yaml can
# stay owned by the server's admin user. The app writes label PDFs to static/; the home
# folder holds gunicorn's control socket and the font cache.
RUN useradd --uid 1000 --create-home eln && chown eln:eln /app/static
USER eln
EXPOSE 5000

HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:5000/ping', timeout=4).status==200 else 1)"

# 1 worker, as on the old server (~160 MB each). --timeout matches the timer client's
# 600 s wait, so a long autofill or peroxide check isn't killed halfway.
CMD ["gunicorn", "-w", "1", "-b", "0.0.0.0:5000", "--timeout", "600", "--access-logfile", "-", "app:app"]
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,32 @@ ignored unless it is set).

## Running the server

### With Docker (the lab server)

`deploy/compose.yaml` builds the image from this checkout and runs it with
`config.yaml` and `secrets.yaml` mounted from the repo root (they are never
baked into the image). The container joins the shared `lab-net` network, where
the edge proxy reaches it at `http://eln-server:5000`; the lab's address stays
`https://eln.ddomlab.org:5000` (Caddy sends `/pth/...` to the PTH container and
everything else here). It also listens on `127.0.0.1:5001` for the timer client
on the same host.

```bash
git clone https://github.com/ddomlab/eln_server.git /opt/eln-server
cd /opt/eln-server
cp config-ex.yaml config.yaml # set eln_url, eln_web_url, IDs
cp secrets.example.yaml secrets.yaml && chmod 600 secrets.yaml # fill in the Slack token etc.
cd deploy && sudo docker compose up -d --build
sudo docker compose ps # expect "healthy"
```

The container runs as uid 1000, so both files must be readable by that user
(and `config.yaml` writable, since `/settings_interface` saves to it).
Update: `git pull && sudo docker compose up -d --build`.
`constraints.txt` pins the Python package versions the image installs.

### Without Docker

Dependencies are managed with [uv](https://docs.astral.sh/uv/) in a local
`.venv` (gitignored):

Expand Down Expand Up @@ -119,3 +145,9 @@ This installs `eln-autofill.timer` (every 10 minutes — adjust `OnCalendar` to
taste, including excluding backup windows) and `eln-peroxide-check.timer`
(May 1 and Nov 1). The key is stored at `/etc/eln-client/api_key`; the server
URL is set via `ELN_SERVER_URL` in the `.service` files.
Pass it at install time to skip editing them; with the Docker deploy on the same
host that's the local port:

```bash
cd client && sudo ELN_SERVER_URL=http://127.0.0.1:5001 ./install.sh
```
8 changes: 8 additions & 0 deletions client/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ install -m 755 "$SCRIPT_DIR/eln_timer_client.py" /opt/eln-client/
install -m 644 "$SCRIPT_DIR"/eln-autofill.{service,timer} /etc/systemd/system/
install -m 644 "$SCRIPT_DIR"/eln-peroxide-check.{service,timer} /etc/systemd/system/

# optional: point the timers at a server other than the default in the .service files,
# e.g. sudo ELN_SERVER_URL=http://127.0.0.1:5001 ./install.sh (the Docker deploy's local port)
if [[ -n ${ELN_SERVER_URL:-} ]]; then
sed -i "s|^Environment=ELN_SERVER_URL=.*|Environment=ELN_SERVER_URL=$ELN_SERVER_URL|" \
/etc/systemd/system/eln-autofill.service /etc/systemd/system/eln-peroxide-check.service
echo "Timers will call $ELN_SERVER_URL"
fi

if [[ ! -f $KEY_FILE ]]; then
SECRETS_FILE="$SCRIPT_DIR/../secrets.yaml"
if [[ ! -f $SECRETS_FILE ]]; then
Expand Down
46 changes: 46 additions & 0 deletions constraints.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Exact versions the offline tests and a local eLabFTW 5.6.12 run passed with (Python 3.11).
# Used by the Dockerfile: pip install -r requirements.txt -c constraints.txt
# To upgrade: bump a version here, rebuild, and run the tests.
blabel==0.1.7
blinker==1.9.0
brotli==1.2.0
certifi==2026.7.22
cffi==2.1.1
charset-normalizer==3.5.1
click==8.5.0
cssselect2==0.10.1
elabapi-python==5.6.0
Flask==3.1.3
flask-cors==6.0.5
fonttools==4.66.0
gunicorn==26.2.0
idna==3.20
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.3
numpy==2.4.6
pandas==3.0.6
pillow==12.3.0
PubChemPy==1.0.5
pycparser==3.0
pydyf==0.12.1
Pygments==2.21.0
pypdf==6.19.0
pyphen==0.18.1
pyStrich==0.20
python-barcode==0.16.1
python-dateutil==2.9.0.post0
PyYAML==6.0.3
qrcode==8.2
rdkit==2026.3.6
requests==2.34.2
six==1.17.0
tabulate==0.10.0
tinycss2==1.5.1
tinyhtml5==2.1.0
typing_extensions==4.16.0
urllib3==2.8.0
weasyprint==70.0
webencodings==0.6.1
Werkzeug==3.1.9
zopfli==0.4.3
44 changes: 44 additions & 0 deletions deploy/compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# eln_server on the lab server (run from this folder).
#
# cp ../config-ex.yaml ../config.yaml # then set eln_url, eln_web_url, IDs
# cp ../secrets.example.yaml ../secrets.yaml && chmod 600 ../secrets.yaml # then fill in
# docker compose up -d --build
#
# The edge proxy (Caddy) reaches this container over the shared "lab-net" network at
# http://eln-server:5000. The 127.0.0.1:5001 port is only for the timer client on this
# host (client/): it isn't reachable from outside the server.
name: eln-server

services:
eln-server:
build: ..
image: eln-server:local
container_name: eln-server
restart: unless-stopped # restart after crashes/reboots, unless you stopped it on purpose
security_opt: [ "no-new-privileges:true" ]
cap_drop: [ ALL ]
mem_limit: 512m # one worker uses ~160 MB; protects eLabFTW on a 2 GB server
ports:
- "127.0.0.1:5001:5000"
volumes:
# both files must exist before the first start; without create_host_path: false,
# Docker would silently create empty folders in their place
- type: bind
source: ../config.yaml # the settings page writes team IDs back to this file
target: /app/config.yaml
bind: { create_host_path: false }
- type: bind
source: ../secrets.yaml
target: /app/secrets.yaml
read_only: true
bind: { create_host_path: false }
networks:
- lab-net
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }

networks:
lab-net:
# created once on the server with: docker network create lab-net
external: true
Loading