Skip to content

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:

sh
git clone https://gitlab.com/sidedoc/sidedoc.git && cd sidedoc
npm ci
scripts/build-static.sh

The 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:

sh
git clone https://gitlab.com/sidedoc/schemas.git ../schemas
export SIDEDOC_SCHEMAS_DIR=$(cd ../schemas && pwd)
npm ci
scripts/build-static.sh --with-schemas

The script runs these steps, if you prefer to run them by hand:

sh
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.html

npm 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 own VITE_SENTRY_DSN, or switch it off at run time in config.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 with SIDEDOC_CSP_META=false. Otherwise the browser enforces both policies, and anything you add to the header, such as an extra connect-src for 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:

js
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:

PathBehaviour
/ and other HTMLCache-Control: no-cache (expires -1).
/config.jsContent-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/envelopeOnly when Sentry is enabled: POST proxied to Sentry (see below).
dotfiles404 (except /.well-known/).
any pathOnly GET and HEAD; 405 otherwise.

Security headers on every response:

text
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; includeSubDomains

About the policy:

  • 'unsafe-eval' is required: the validator (ajv) compiles JSON Schemas with new Function.
  • There is deliberately no 'unsafe-inline' for scripts. The build disables Vite's inline module-preload polyfill for this reason.
  • frame-ancestors and X-Frame-Options control 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:

nginx
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):

nginx
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:

nginx
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.