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 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 11:38:53 +02:00
parent 92f44a2916
commit 34ee2856b2
5 changed files with 127 additions and 4 deletions

View File

@@ -1,6 +1,8 @@
.env .env
.env.example .env.example
.env.testcopy
domains.json domains.json
data
node_modules node_modules
.git .git
*.md *.md

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.env
.env.testcopy
node_modules
data/

94
DEPLOY.md Normal file
View File

@@ -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=<your DeepL Growth 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://<your-host>/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 <container>:/app/data/domains.json
docker restart <container>
```
## 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.

View File

@@ -7,10 +7,21 @@ FROM node:18-alpine
WORKDIR /app WORKDIR /app
COPY server.js index.html package.json ./ COPY server.js index.html package.json ./
# DEEPL_API_KEY is passed at "docker run" time (via -e or --env-file), not # DEEPL_API_KEY is passed at runtime (docker run -e / --env-file, or the
# baked into the image. domains.json should be bind-mounted so glossary # Dokploy "Environment" tab), never baked into the image.
# data survives container restarts/recreation (see README). #
# 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 PORT=3000
ENV DOMAINS_FILE=/app/data/domains.json
RUN mkdir -p /app/data
VOLUME ["/app/data"]
EXPOSE 3000 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"] CMD ["node", "server.js"]

View File

@@ -66,7 +66,10 @@ function baseUrlFor(apiKey) {
// the server disk, not in any user's browser, so every visitor to the site // the server disk, not in any user's browser, so every visitor to the site
// sees and edits the same shared glossaries. // 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 VALID_DOMAINS = ['scheer-group', 'scheer-ids', 'scheer-pas', 'scheer-imc'];
const DOMAIN_LABELS = { const DOMAIN_LABELS = {
'scheer-group': 'Scheer Group', 'scheer-group': 'Scheer Group',
@@ -137,6 +140,7 @@ function loadDomains() {
} }
function saveDomains(data) { function saveDomains(data) {
fs.mkdirSync(path.dirname(DOMAINS_FILE), { recursive: true });
fs.writeFileSync(DOMAINS_FILE, JSON.stringify(data, null, 2)); 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 = { const routes = {
'GET /health': handleHealth,
'POST /api/translate': handleTranslate, 'POST /api/translate': handleTranslate,
'POST /api/usage': handleUsage, 'POST /api/usage': handleUsage,
'POST /api/document/upload': handleDocumentUpload, 'POST /api/document/upload': handleDocumentUpload,