Static hosting without Docker
SideDoc is a static site, so any web server can host it. You build the files yourself and configure the server to send the same headers and routes as the container. If you can run containers, the Docker image does all of this for you.
Build
You need Node.js 22 and npm.
Without a schemas checkout
Schemas, vocabularies and the JSON generated from them are committed to the repository, so the quickest build needs nothing else. This is what the image build does:
git clone https://gitlab.com/sidedoc/sidedoc.git && cd sidedoc
npm ci
scripts/build-static.shThe result is app/sidedoc-frontend/dist/.
There is no separate backend build. The frontend's Vite config resolves @sidedoc/backend to its TypeScript source (app/sidedoc-backend/src/index.ts) and bundles it, so the script only runs vite build. A stale app/sidedoc-backend/dist/ is never used.
With a schemas checkout
To regenerate the vocabulary index and DCAT mappings from the schemas repository first (for example while working on a new datamodel version), point SIDEDOC_SCHEMAS_DIR at a checkout:
git clone https://gitlab.com/sidedoc/schemas.git ../schemas
export SIDEDOC_SCHEMAS_DIR=$(cd ../schemas && pwd)
npm ci
scripts/build-static.sh --with-schemasThe script runs these steps, if you prefer to run them by hand:
npm run prebuild -w @sidedoc/backend # only with a schemas checkout: regenerates the JSON
cd app/sidedoc-frontend
npx vite build --base=./ --mode standalone
mv dist/standalone.html dist/index.htmlnpm run build at the repository root builds the backend library (dist/, for external consumers) and then the workspace app profile (index.html), not the standalone editor that sidedoc.app and the image serve.
Build-time settings
Set these in the environment of the build or in app/sidedoc-frontend/.env.local. All of them are listed under Build-time variables.
- Sentry. A production build reports browser errors to SideDoc's own Sentry project unless you say otherwise. Build with
VITE_SENTRY_ENABLED=false, or set your ownVITE_SENTRY_DSN, or switch it off at run time inconfig.js(below). - CSP. By default the build embeds the Content-Security-Policy as a
<meta>tag. If your server sends the CSP as a header (recommended, see below), build withSIDEDOC_CSP_META=false. Otherwise the browser enforces both policies, and anything you add to the header, such as an extraconnect-srcfor an S3 endpoint, is still blocked by the<meta>policy.
Run-time settings: config.js
dist/config.js is loaded before the app. In the build it is an empty placeholder. Replace it on the server to change settings without rebuilding; keys are the VITE_* names, values are strings:
window.__SIDEDOC_CONFIG__ = Object.freeze({
VITE_SENTRY_ENABLED: "false",
VITE_SCHEMA_VERSION: "0.2.2",
})This is the file the container generates from its SIDEDOC_* variables. Serve it with Cache-Control: no-cache so changes take effect on the next page load, and remember that your deploy step may overwrite it with the placeholder.
What the server must do
The container's behaviour, which a manual setup should replicate:
| Path | Behaviour |
|---|---|
/ and other HTML | Cache-Control: no-cache (expires -1). |
/config.js | Content-Type: application/javascript, Cache-Control: no-cache. |
/assets/* | Content-hashed: cache for a year (expires max). |
/schemas/* | Access-Control-Allow-Origin: *, cache 1 hour. |
/vocab/* | Access-Control-Allow-Origin: *, cache 1 hour, .jsonld as application/ld+json. |
/vocab/<scheme> | 302 to <scheme>.jsonld if that file exists, else 404. |
/monitoring/envelope | Only when Sentry is enabled: POST proxied to Sentry (see below). |
| dotfiles | 404 (except /.well-known/). |
| any path | Only GET and HEAD; 405 otherwise. |
Security headers on every response:
Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self'; frame-src 'self'; worker-src 'self' blob:
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: cross-origin
X-Frame-Options: SAMEORIGIN
Strict-Transport-Security: max-age=31536000; includeSubDomainsAbout the policy:
'unsafe-eval'is required: the validator (ajv) compiles JSON Schemas withnew Function.- There is deliberately no
'unsafe-inline'for scripts. The build disables Vite's inline module-preload polyfill for this reason. frame-ancestorsandX-Frame-Optionscontrol who may embed SideDoc. They only work as headers, not in a<meta>tag.- Add origins the browser must reach to
connect-src, for example an S3 endpoint. - Send HSTS only when the site is HTTPS-only.
The policy is defined in two places in the repository: CSP in app/sidedoc-frontend/vite.config.ts (for the <meta> tag) and in docker/entrypoint.sh (for the header). Compare with both after an upgrade.
nginx example
Put the headers in a file, because nginx drops server-level add_header directives in every location that adds its own header. The container solves this the same way.
/etc/nginx/snippets/sidedoc-headers.conf:
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self'; frame-src 'self'; worker-src 'self' blob:" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "cross-origin" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;Site configuration (TLS settings omitted):
map $request_method $sidedoc_method_not_allowed {
default 1;
GET 0;
HEAD 0;
}
server {
listen 443 ssl;
http2 on;
server_name sidedoc.example.org;
# ssl_certificate ...; ssl_certificate_key ...;
root /var/www/sidedoc; # contents of app/sidedoc-frontend/dist/
index index.html;
charset utf-8;
charset_types application/javascript application/json application/ld+json text/css;
server_tokens off;
client_max_body_size 1k;
absolute_redirect off;
gzip on;
gzip_types text/css application/javascript application/json application/ld+json image/svg+xml;
include /etc/nginx/snippets/sidedoc-headers.conf;
location = /config.js {
if ($sidedoc_method_not_allowed) { return 405; }
default_type application/javascript;
expires -1;
}
location /assets/ {
if ($sidedoc_method_not_allowed) { return 405; }
expires max;
try_files $uri =404;
}
location /schemas/ {
if ($sidedoc_method_not_allowed) { return 405; }
include /etc/nginx/snippets/sidedoc-headers.conf;
add_header Access-Control-Allow-Origin "*" always;
expires 1h;
try_files $uri =404;
}
location ~ ^/vocab/(?<vocab_scheme>[A-Za-z0-9_-]+)$ {
if (-f $document_root/vocab/$vocab_scheme.jsonld) {
return 302 $vocab_scheme.jsonld;
}
return 404;
}
location /vocab/ {
if ($sidedoc_method_not_allowed) { return 405; }
types {
application/ld+json jsonld;
application/json json;
}
include /etc/nginx/snippets/sidedoc-headers.conf;
add_header Access-Control-Allow-Origin "*" always;
expires 1h;
try_files $uri =404;
}
location ~ /\.(?!well-known/) {
return 404;
}
location / {
if ($sidedoc_method_not_allowed) { return 405; }
expires -1;
try_files $uri $uri/ =404;
}
}docker/nginx.conf and docker/entrypoint.sh are the reference: when in doubt, compare with them.
app/sidedoc-frontend/deploy/nginx-security-headers.conf is an older header snippet from the sidedoc.app beta. It assumes the <meta> CSP is embedded and allows direct Sentry ingest; prefer the headers above.
Sentry tunnel
If Sentry is enabled, browsers post events to /monitoring/envelope on your own origin. Proxy that path to the ingest host and project from your DSN (https://<key>@<host>/<project>), and do not forward client addresses or cookies:
location = /monitoring/envelope {
limit_except POST { deny all; }
client_max_body_size 1m;
proxy_pass https://o123456.ingest.de.sentry.io/api/4567/envelope/;
proxy_set_header Host o123456.ingest.de.sentry.io;
proxy_set_header X-Forwarded-For "";
proxy_set_header Cookie "";
proxy_ssl_server_name on;
}Without the proxy, events fail silently. If you cannot add it, build with an empty VITE_SENTRY_TUNNEL to send events straight to *.ingest.sentry.io (allowed by the <meta> CSP, not by the header above).
Other servers
Apache, Caddy, S3 website hosting, GitLab Pages and similar work as long as they can set the headers above. Hosts that cannot set response headers can serve SideDoc with the embedded <meta> CSP (the default build), but then have no frame-ancestors protection and no HSTS. The /vocab/<scheme> redirect makes SKOS scheme URLs resolvable for other tools; leave it out if nobody needs that.