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:
@@ -1,6 +1,8 @@
|
||||
.env
|
||||
.env.example
|
||||
.env.testcopy
|
||||
domains.json
|
||||
data
|
||||
node_modules
|
||||
.git
|
||||
*.md
|
||||
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
.env.testcopy
|
||||
node_modules
|
||||
data/
|
||||
94
DEPLOY.md
Normal file
94
DEPLOY.md
Normal 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.
|
||||
17
Dockerfile
17
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"]
|
||||
|
||||
14
server.js
14
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,
|
||||
|
||||
Reference in New Issue
Block a user