From 34ee2856b20cae0125f888a2d0dbfbb71876bf33 Mon Sep 17 00:00:00 2001 From: Pascal Linxweiler Date: Fri, 31 Jul 2026 11:38:53 +0200 Subject: [PATCH] Make the container deployable on Dokploy domains.json was written next to server.js, i.e. into the image filesystem, so every redeploy discarded the domain -> glossary_id mappings. Its path is now overridable via DOMAINS_FILE and the image defaults it to /app/data, a subdirectory so that mounting a volume there does not shadow server.js and index.html in /app. Also adds a GET /health endpoint plus a Docker HEALTHCHECK that uses it. The probe deliberately does not call DeepL, so an upstream DeepL outage does not get the container restarted. .gitignore is new; .env holds a real API key and was previously untracked only by convention. Co-Authored-By: Claude --- .dockerignore | 2 ++ .gitignore | 4 +++ DEPLOY.md | 94 +++++++++++++++++++++++++++++++++++++++++++++++++++ Dockerfile | 17 ++++++++-- server.js | 14 +++++++- 5 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 .gitignore create mode 100644 DEPLOY.md diff --git a/.dockerignore b/.dockerignore index 39ad3a2..9a0e036 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,8 @@ .env .env.example +.env.testcopy domains.json +data node_modules .git *.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef39ab3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +.env.testcopy +node_modules +data/ diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..e433c78 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,94 @@ +# Deploying to Dokploy + +The app is a single Node process with no dependencies. Dokploy builds it from +the `Dockerfile` in this repo and puts Traefik in front of it. + +## 1. Push the repo to a Git host + +Dokploy pulls from GitHub/GitLab/Gitea (or a raw Git URL over SSH). + +**Before the first push:** `.env` and `.env.testcopy` contain a real DeepL API +key. They are untracked from now on via `.gitignore`, but if they were already +committed they still exist in history — remove them from tracking and rotate +the key rather than relying on the file being gone from the working tree. + +## 2. Create the application in Dokploy + +Project → **Create Service** → **Application**. + +| Field | Value | +| ------------- | ------------------------------ | +| Source | Git provider + this repo/branch | +| Build Type | **Dockerfile** | +| Dockerfile | `Dockerfile` | + +## 3. Environment variables + +**Environment** tab: + +``` +DEEPL_API_KEY= +``` + +`PORT` and `DOMAINS_FILE` are already set in the Dockerfile — do not override +them unless you also change the volume mount path to match. + +The key is server-side only; it is never sent to the browser. + +## 4. Volume (required) + +Without this, every redeploy resets all glossary mappings. + +**Advanced → Volumes → Add** : + +| Field | Value | +| ----------- | -------------- | +| Type | Volume Mount | +| Volume Name | `deepl-data` | +| Mount Path | `/app/data` | + +`DOMAINS_FILE=/app/data/domains.json` writes into that volume. The path is a +subdirectory of `/app` on purpose — mounting `/app` itself would hide +`server.js` and `index.html`. + +## 5. Domain + +**Domains** tab → Add: + +| Field | Value | +| ------------- | ------------------------ | +| Host | your hostname | +| Container Port| `3000` | +| HTTPS | on, Let's Encrypt | + +## 6. Deploy + +Hit **Deploy**. Verify with: + +``` +curl https:///health +# {"status":"ok","apiKeyConfigured":true} +``` + +`apiKeyConfigured: false` means `DEEPL_API_KEY` did not reach the container. + +## Seeding existing glossary mappings + +A fresh volume starts empty and the app writes a default `domains.json` with +all four domains and no glossaries. The `domains.json` in this repo is **not** +copied into the image (it is in `.dockerignore`), so any glossary IDs recorded +there are not carried over. Either re-save the glossaries through the UI after +the first deploy, or copy the file in once: + +``` +docker cp domains.json :/app/data/domains.json +docker restart +``` + +## Notes + +- The app has **no authentication**. Anything on the public domain can use your + DeepL quota and edit the shared glossaries. Put Traefik basic auth or an + IP allowlist in front of it if it is internet-facing. +- Docker `HEALTHCHECK` hits `/health` every 30s. It does not call DeepL, so a + DeepL outage will not restart the container. diff --git a/Dockerfile b/Dockerfile index 1a15a42..4ca7daf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,10 +7,21 @@ FROM node:18-alpine WORKDIR /app COPY server.js index.html package.json ./ -# DEEPL_API_KEY is passed at "docker run" time (via -e or --env-file), not -# baked into the image. domains.json should be bind-mounted so glossary -# data survives container restarts/recreation (see README). +# DEEPL_API_KEY is passed at runtime (docker run -e / --env-file, or the +# Dokploy "Environment" tab), never baked into the image. +# +# domains.json holds which DeepL glossary_id belongs to which domain and +# direction. It must live on a mounted volume, otherwise every redeploy +# builds a fresh filesystem and the mappings are lost. /app/data is a +# subdirectory so mounting it does not shadow the app files in /app. ENV PORT=3000 +ENV DOMAINS_FILE=/app/data/domains.json +RUN mkdir -p /app/data +VOLUME ["/app/data"] + EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD wget -qO- http://127.0.0.1:3000/health || exit 1 + CMD ["node", "server.js"] diff --git a/server.js b/server.js index 4cbd9e8..da79478 100644 --- a/server.js +++ b/server.js @@ -66,7 +66,10 @@ function baseUrlFor(apiKey) { // the server disk, not in any user's browser, so every visitor to the site // sees and edits the same shared glossaries. -const DOMAINS_FILE = path.join(__dirname, 'domains.json'); +// The path is overridable via DOMAINS_FILE so a container deployment can point +// it at a mounted volume (e.g. /app/data/domains.json) instead of the image's +// own filesystem, which is recreated on every redeploy. +const DOMAINS_FILE = process.env.DOMAINS_FILE || path.join(__dirname, 'domains.json'); const VALID_DOMAINS = ['scheer-group', 'scheer-ids', 'scheer-pas', 'scheer-imc']; const DOMAIN_LABELS = { 'scheer-group': 'Scheer Group', @@ -137,6 +140,7 @@ function loadDomains() { } function saveDomains(data) { + fs.mkdirSync(path.dirname(DOMAINS_FILE), { recursive: true }); fs.writeFileSync(DOMAINS_FILE, JSON.stringify(data, null, 2)); } @@ -448,7 +452,15 @@ function serveStatic(req, res) { }); } +// Liveness probe for container platforms (Docker HEALTHCHECK, Dokploy). +// Deliberately does not call DeepL: it answers "is this process serving?", +// not "is the upstream API up", so a DeepL outage doesn't restart the app. +function handleHealth(req, res) { + sendJson(res, 200, { status: 'ok', apiKeyConfigured: Boolean(DEEPL_API_KEY) }); +} + const routes = { + 'GET /health': handleHealth, 'POST /api/translate': handleTranslate, 'POST /api/usage': handleUsage, 'POST /api/document/upload': handleDocumentUpload,