Skip to content

Self-Hosting OS-APS

Hosted demo

We operate a reference instance at os-aps.sciflow.net that always tracks the latest stable container images.

A development build is available at os-aps-next.sciflow.net. Use it to preview upcoming changes; it may be unstable.

Intended audience

This guide targets technical university IT and library teams who want to operate their own instance of the editor. If you are an author or publisher looking for a hosted solution, visit sciflow.net instead. We are also working on a hosting service for OS-APS - stay tuned.

Questions are welcome in the #development channel on Slack.

  • Mirror the template repository published under os-aps/community on GitLab and keep it in sync with your institutional Git service.
  • Mount a dedicated, read-only font volume and point FONT_PATH to it so your exports use your university's typography.
  • Store manuscripts and uploads in an S3 bucket with versioning and automated backups instead of relying solely on a Docker volume.
  • Run the application through Docker Compose (or your orchestrator of choice) behind a reverse proxy that terminates TLS for your institutional domain.

This guide assumes you are part of a university IT or library team and are comfortable operating Linux servers, Docker, Git, and S3-compatible storage.

Quick start – local evaluation

Need a quick look at OS-APS before investing in the full setup? You can run a self-contained demo in a few minutes. The container ships with the default export templates and downloads required open-source fonts on first start, so no additional assets are needed.

mkdir osaps-demo && cd osaps-demo
docker run \
  --rm \
  -p 127.0.0.1:3000:3000 \
  --name os-aps-demo \
  -v "$(pwd):/data" \
  -e LOCAL_STORAGE_PATH=/data/manuscripts \
  -e INSTANCE_TITLE="OS-APS Demo" \
  registry.gitlab.com/sciflow/development/server:latest
  • Using PowerShell instead of a Unix shell? Replace $(pwd) with ${PWD} and either place the command on one line or use backticks (\) for line continuations.
  • Browse to http://localhost:3000 and create an administrator account when prompted.
  • Press Ctrl+C to stop the container. Your manuscripts and configuration stay in the osaps-demo directory so you can restart later.
  • Optional: add -e FONT_PATH=/data/fonts and drop fonts into ./fonts if you want to test custom typography, or run the image transformation sidecar for WMF/EMF support.

What's bundled in the image

The published server image is built on node:22-alpine3.20-pandoc3.9 and includes Pandoc 3.9 + PrinceXML 16 for PDF export. The file-transform-sidecar image (when you run it) adds LibreOffice 26.2.2 for .docx.odt conversions. The Node.js runtime version, Pandoc availability, and the commit SHA the image was built from are all visible on the /status operator dashboard once the container is running.

Building from source? Pass --build-arg CI_COMMIT_SHA=$(git rev-parse HEAD) to docker build so /status can show the exact commit your image was produced from — without it the dashboard falls back to git rev-parse HEAD at runtime, which is only meaningful when the container has access to the source checkout.

Once you are ready to go beyond local testing, proceed with the production-oriented steps below.

Production

Prerequisites

  • A Linux host (or VM) with Docker Engine 24+ and the Docker Compose plugin installed.
  • Git access to the os-aps/community group on GitLab. The repositories are public (MIT-licensed), so read access works anonymously; add a deploy key or token only if your automation needs write access or mirrors into a private namespace.
  • An institutional Git service (GitLab, Gitea, GitHub Enterprise, …) where you can host your customized template repository.
  • An S3-compatible endpoint (AWS S3, MinIO, or on-prem object storage) where you can create buckets with versioning and lifecycle policies.
  • DNS control for the domain you want to publish (e.g. aps.university.edu) and an option to obtain TLS certificates.
  • Optional: network access to SMTP, institutional SSO, logging or monitoring stacks if you plan to integrate them after the base install.

Step 1 – Prepare your template repository

The easiest way to stay current with template updates is to start from a community repository. You can mirror it into your institutional Git platform, but we encourage universities to collaborate directly under the os-aps/community umbrella so improvements remain available to everyone. You can also copy templates from the source code at /templates. These are the templates that ship with every instance of the software.

  1. Request a repository in the os-aps/community group (or create one if you have permission). Alternatively, create an empty project in your institutional Git service, for example git@git.university.edu:press/os-aps-templates.git.
  2. On an administration workstation, create a bare mirror of the community repository:

git clone --mirror https://gitlab.com/os-aps/community/templates.git
cd templates.git
git remote add campus git@git.university.edu:press/os-aps-templates.git
git push --mirror campus
3. Keep the upstream remote so you can pull fixes:

git remote rename origin upstream
4. Whenever templates change upstream, synchronize them into your campus repository and push:

git fetch upstream
git push --mirror campus

Automate template sync

Run git-sync from a scheduled job or as a sidecar to keep your volume current. Example for a one-shot sync into a named Docker volume:

docker volume create osaps-templates
docker run --rm \
  -v osaps-templates:/git \
  registry.k8s.io/git-sync/git-sync:v4.1.0 \
  --repo=git@git.university.edu:press/os-aps-templates.git \
  --branch=main \
  --root=/git \
  --link=. \
  --one-time

Adjust the repository URL to your internal mirror. Use SSH keys or tokens that provide read-only access.

Once the volume is ready, the application can mount it read-only and expose the templates via TEMPLATE_SOURCE=/templates.

Step 2 – Prepare font assets

Document exports look best if they use the same fonts as your institutional publications.

  1. Collect the font files (OTF or TTF) that you have the right to distribute.
  2. Store them in a dedicated directory or Docker volume so they can be mounted read-only into the container.
  3. Point FONT_PATH at that mount (e.g. /fonts).

Example initialization:

docker volume create osaps-fonts
# Copy your font files into the volume (replace /mnt/fonts with your source)
docker run --rm -v osaps-fonts:/fonts -v /mnt/fonts:/source busybox cp /source/*.ttf /fonts/

By separating fonts into their own volume you can keep licensing under control and audit which font versions are in use.

Step 3 – Configure durable storage

We strongly recommend storing manuscripts and uploads in S3-compatible object storage with versioning and backup policies. This protects research output against operator error and ransomware and simplifies scaling across hosts.

  1. Create a bucket dedicated to OS-APS (for example os-aps-manuscripts).
  2. Enable versioning on the bucket. With AWS you can run:

aws s3api put-bucket-versioning \
  --bucket os-aps-manuscripts \
  --versioning-configuration Status=Enabled
3. Configure lifecycle rules that transition stale object versions to a cheaper storage class and permanently expire deleted versions after your retention period. Example:

cat <<'JSON' > lifecycle.json
{
  "Rules": [
    {
      "ID": "Archive old versions",
      "Status": "Enabled",
      "Filter": {"Prefix": ""},
      "NoncurrentVersionTransition": {"NoncurrentDays": 30, "StorageClass": "GLACIER"},
      "NoncurrentVersionExpiration": {"NoncurrentDays": 365}
    }
  ]
}
JSON
aws s3api put-bucket-lifecycle-configuration \
  --bucket os-aps-manuscripts \
  --lifecycle-configuration file://lifecycle.json
4. Create an access key pair with permissions limited to that bucket (read, write, list, delete, and the ability to perform multipart uploads).

If you cannot use S3, mount a persistent volume (/var/opt/osaps) and back it up with your existing tooling, but note that you lose automatic versioning.

Prefixes the server writes inside the bucket

Once running, OS-APS uses these prefixes alongside your manuscripts. They are operational data, not user content:

  • _queue/jobs/<id>.json — durable descriptor for each AI analysis job. Survives process restarts so an interrupted job is re-queued on startup. A job descriptor is deleted as soon as the job finishes (success or terminal failure).
  • _status/transactions/<id>.json — per-import + per-AI sidecar with timings, logs, and the server context at the time of the run. Powers the /status/<transactionId> detail page.
  • <project>/<doc>.timings.json — per-document AI analysis timing sidecar.
  • <project>/.analysis/<doc>.analysis-lock.json — small lock file the queue uses to mark a doc as queued/running/done/error.

These prefixes are small (a few KB per record) but grow with workload. Include them in any lifecycle rule that targets the whole bucket. The _queue/jobs/ prefix is self-cleaning; _status/transactions/ accumulates one record per import or analysis and is the right candidate for a noncurrent-version expiry rule.

Step 4 – Define the application stack

The following Docker Compose file demonstrates a production-friendly layout with separate volumes for application data, templates, and fonts, plus S3 configuration.

version: "3.9"

services:
  osaps:
    image: registry.gitlab.com/sciflow/development/server:latest
    container_name: os-aps
    restart: always
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      INSTANCE_TITLE: "University Press"
      INSTANCE_URL: "https://aps.university.edu"
      LOG_LEVEL: info
      LOCAL_STORAGE_PATH: /var/opt/osaps/manuscripts
      TEMPLATE_SOURCE: /templates
      FONT_PATH: /fonts
      S3_ENDPOINT: "https://s3.example.edu"
      S3_REGION: "eu-central-1"
      S3_ACCESS_KEY: "${OSAPS_S3_ACCESS_KEY}"
      S3_SECRET_KEY: "${OSAPS_S3_SECRET_KEY}"
      S3_IMPORTER_BUCKET: "os-aps-manuscripts"
      # Optional: enable AI-assisted import (see AI-assisted import section below)
      # OPENAI_API_KEY: "${OPENAI_API_KEY}"
      # Optional: gate /status and /api/status/* behind HTTP Basic auth. Leave
      # unset on private networks; set to a random string on anything reachable
      # from the public internet. /healthz, /readiness, /metrics stay open.
      # STATUS_PASSWORD: "${OSAPS_STATUS_PASSWORD}"
      # Optional: tune the in-process AI analysis queue. Defaults are 2 and 2.
      # Raise concurrency on hosts with spare RAM + CPU; lower it if you see
      # OpenAI rate-limit cascades or event-loop p99 spikes on /status.
      # ANALYSIS_QUEUE_CONCURRENCY: "2"
      # ANALYSIS_QUEUE_MAX_ATTEMPTS: "2"
      # Optional: cap plain imports. Default is 15 minutes.
      # IMPORT_TIMEOUT_MS: "900000"
    volumes:
      - osaps-data:/var/opt/osaps
      - osaps-templates:/templates:ro
      - osaps-fonts:/fonts:ro

volumes:
  osaps-data:
  osaps-templates:
    external: true
  osaps-fonts:
    external: true

Save the file as docker-compose.yml. Store sensitive values such as S3_ACCESS_KEY and S3_SECRET_KEY in an .env file next to it (the Compose plugin loads it automatically).

Environment variables of note

  • INSTANCE_TITLE: Label displayed in the UI header.
  • INSTANCE_URL: Public base URL. Set it once your reverse proxy is ready so sessions and links use the official domain.
  • LOCAL_STORAGE_PATH: Used for temporary working files and cached exports. It should live on a persistent volume even when S3 is configured.
  • TEMPLATE_SOURCE: Path inside the container that points to your template volume.
  • FONT_PATH: Directory with the mounted fonts.
  • S3_*: Connection details for your object storage. The server reads S3_ENDPOINT, S3_IMPORTER_BUCKET (required — there is no default), S3_ACCESS_KEY, S3_SECRET_KEY, and S3_REGION (falls back to AWS_REGION, then us-east-1). Path-style addressing is always used, so MinIO works without an extra flag. Leave the whole block unset only for evaluation deployments that rely on local storage.
  • ALLOW_UNAUTHORIZED_TLS: Optional. Set to true to skip TLS certificate verification on the S3 endpoint — useful only for MinIO or in-cluster object storage with a self-signed cert. Do not set it against AWS or any public endpoint.
  • TRANSFORM_IMAGE_URL: Optional URL of the transformation sidecar (see below) when you need WMF/EMF support.
  • OPENAI_API_KEY: Optional. Enables AI-assisted import features. When set, conversions go through the in-process queue (default 2 in parallel, retries on transient 429/5xx with exponential backoff). See AI-assisted import for setup steps and Tuning the AI queue for sizing.
  • PRINCE_LICENSE_PATH: Optional path to a PrinceXML 16 license file inside the container (the image ships with PrinceXML 16 for PDF export). Unlicensed copies emit a watermark; provide a prince-license.dat from PrinceXML to silence it.
  • STATUS_PASSWORD: Optional. When set, gates /status and /api/status/* behind HTTP Basic auth (any username, this string as the password). Leave unset on private networks; set on any deployment reachable from the public internet. /healthz, /readiness, /metrics remain open so k8s liveness/readiness probes and Prometheus scrapers keep working.
  • ANALYSIS_QUEUE_CONCURRENCY: Optional. Max concurrent AI analyses (default 2). See Monitoring with /status for tuning advice.
  • ANALYSIS_QUEUE_MAX_ATTEMPTS: Optional. Maximum retries for retryable provider errors (default 2). The slot is held during exponential backoff so a transient rate limit does not let other jobs jump ahead.
  • IMPORT_TIMEOUT_MS: Optional. Plain-import cutoff in milliseconds (default 900000, i.e. 15 minutes). When exceeded, the lock flips to error with an actionable message.

Step 5 – Launch and verify

  1. Start the stack:
docker compose up -d
  1. Follow the logs during the first startup:
docker compose logs -f
  1. When the app reports Server started on port 3000, access http://localhost:3000 (or your reverse proxy URL) and create the first administrator account.
  2. Upload a template-managed manuscript and confirm that exports pick up your custom templates and fonts.
  3. Verify that objects appear in your S3 bucket and that versioning creates multiple revisions when you overwrite files.

Keeping templates synchronized on the server

If you prefer to keep everything contained on the host, schedule git-sync as a systemd timer, cron job, or container:

cat <<'EOF' | sudo tee /etc/systemd/system/osaps-template-sync.service
[Unit]
Description=Sync OS-APS templates
After=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/bin/docker run --rm \
  -v osaps-templates:/git \
  registry.k8s.io/git-sync/git-sync:v4.1.0 \
  --repo=git@git.university.edu:press/os-aps-templates.git \
  --branch=main \
  --root=/git \
  --link=.
EOF

Add a companion timer and reload systemd:

cat <<'EOF' | sudo tee /etc/systemd/system/osaps-template-sync.timer
[Unit]
Description=Schedule OS-APS template sync

[Timer]
OnCalendar=hourly
Persistent=true

[Install]
WantedBy=timers.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now osaps-template-sync.timer

Optional components

Image transformation sidecar

Some institutions require support for WMF/EMF files or PDF post-processing. Run the sidecar next to the main container and reference it via TRANSFORM_IMAGE_URL:

docker run \
  --rm \
  -d \
  -p 127.0.0.1:3001:3001 \
  --name os-aps-transform \
  registry.gitlab.com/sciflow/development/file-transform-sidecar:latest

Set TRANSFORM_IMAGE_URL=http://os-aps-transform:3001 (or http://localhost:3001 if you keep it on the host network).

The sidecar bundles LibreOffice 26.2.2 (Debian trixie base) for .docx.odt conversions. If your workload includes many WMF/EMF images or OLE/MathType equations, treat the sidecar as required rather than optional — without it those assets land as placeholders in the editor.

Windows administration

When running ad-hoc docker run commands from PowerShell, break lines with backticks (```). The Compose setup above is cross-platform and avoids quoting issues.

Monitoring with /status

OS-APS ships with an operator dashboard at /status that shows everything you need to triage a running deployment without shelling into the container.

What you see:

  • Overall health — a single healthy / degraded badge driven by the reachability probes, event-loop responsiveness, and heap pressure signals below. When it flips to degraded, a "what needs attention" list explains why.
  • Build info — uptime, the commit SHA the running image was built from (set by the build pipeline via --build-arg CI_COMMIT_SHA; falls back to git rev-parse HEAD on local builds), and the Node.js runtime version.
  • Queue state — how many AI analyses are running (active) and waiting (queued), with the configured concurrency and maxAttempts next to them.
  • Reachability probes — S3 endpoint, OpenAI (when OPENAI_API_KEY is set), Zotero. Each shows latency and a green tick or a clear failure reason. The filesystem probe reports free disk space.
  • Responsiveness — event-loop p50/p99 in milliseconds, heap used / limit, GC pause totals. p99 spikes correlate with blocked event-loop time during a stage and point to bottlenecks.
  • Environment — Pandoc version + features as detected at startup, templates mount status (TEMPLATE_SOURCE mounted yes/no, template count).
  • Throughput stats — last 1h and 24h windows: total runs, success/failure counts, error rate, avg run time, avg queue wait, top failure stages, per-stage p50/p95. Retries are collapsed into a single logical job so the error rate reflects user-visible outcomes, not attempt counts.

Click any transaction id under the activity list to open /status/<transactionId> — the per-run detail page with the full stage breakdown chart, downloadable JSON bundle (server context + import logs + AI logs in one file), and live polling while the AI phase is still running.

Health and readiness probes

For Kubernetes (or any orchestrator) wire your liveness / readiness / metrics targets to these endpoints. They are intentionally not gated by STATUS_PASSWORD, so probes keep working when the dashboard is locked down.

Endpoint Purpose
/healthz Process is up and the event loop is responsive. Suitable for liveness probes.
/readiness Server has finished bootstrapping (S3 reachable, queue recovery complete, templates mounted). Suitable for readiness probes — when this returns 5xx, the orchestrator will route traffic away.
/metrics Prometheus exposition format. Operational metrics suitable for scraping.

/api/status/dashboard returns the same view-model the dashboard renders, as JSON. Use it for custom alerting (alert on overall === "degraded" and inspect overallReasons).

Tuning the AI queue

AI analyses run through an in-process FIFO queue, capped at ANALYSIS_QUEUE_CONCURRENCY (default 2). The cap exists because each conversion streams a .docx through JSDOM/ProseMirror, makes 6–8 sequential OpenAI calls, and pulls a base64 image set into memory — running too many in parallel produces memory pressure and OpenAI rate-limit cascades.

Rules of thumb for tuning:

  • Small VM (≤ 4 GB RAM): leave concurrency at 2 (or lower to 1 if you see heap-used staying above 70%).
  • Larger host (8 GB+ RAM, 4+ CPU cores): raise concurrency to 3–4 if /status shows event-loop p99 staying under ~150 ms and heap-used under 70%. Bumping beyond your OpenAI tier's effective concurrency just buys throttling.
  • You see a lot of retryable: true failures on /status — that is the queue recovering from transient OpenAI 429/5xx. Healthy; no action. If it happens constantly, contact OpenAI to raise your tier instead of raising the local concurrency.
  • Imports are timing out — raise IMPORT_TIMEOUT_MS (e.g. 1800000 for 30 minutes) if your typical document needs more than 15 minutes to convert through Pandoc.

The queue persists its job descriptors to _queue/jobs/<id>.json in your S3 bucket so a process restart does not lose work. A descriptor is removed as soon as the job finishes; if you see them piling up, check the /status/<id> detail page of one to see where the pipeline is stuck.

Stuck jobs

If a single doc gets stuck and refuses to clear (status stays running or queued long after expected):

  1. Open /status/<transactionId> and download the JSON bundle — the stage field and the log tail explain what failed.
  2. As a last resort, delete the lock file at <project>/.analysis/<doc>.analysis-lock.json and the queue descriptor at _queue/jobs/<escaped-doc-id>.json from your bucket; the user can re-trigger the analysis from the editor afterwards.

AI-assisted import

The editor can use OpenAI to detect document structure, authors, references, in-text citations, and generate image alt text during import. These features are optional — when no API key is configured, the import screen hides the AI settings panel and documents are imported using rule-based parsing.

To enable AI-assisted import:

  1. Obtain an API key from your OpenAI account dashboard.
  2. Add the key to your .env file:

    OPENAI_API_KEY=sk-...
    
  3. Reference it in your Compose environment block (already included as a comment in the example above):

    OPENAI_API_KEY: "${OPENAI_API_KEY}"
    
  4. Restart the stack: docker compose up -d

Users will see the AI settings panel on the import screen once the key is active. See the User Manual for what each setting does.

AI analyses are funneled through an in-process queue capped at ANALYSIS_QUEUE_CONCURRENCY (default 2). See Tuning the AI queue for sizing advice and how to monitor the queue in production.

Development mode

If you plan to modify exporter code or contribute upstream, see the Development Setup Guide for instructions on building the stack locally.

Setup on various environments

Reverse proxy example (Apache)

Serve the application through a reverse proxy that terminates TLS and forwards traffic to port 3000 on the Docker host. The snippets below target Apache 2.4.58 running on the same machine as the containers.

Enable the required modules once:

sudo a2enmod proxy proxy_http headers ssl
sudo systemctl reload apache2

Port 80 virtual host (/etc/apache2/sites-available/000-default.conf)

<VirtualHost *:80>
    ServerName aps.university.edu
    ServerAdmin webmaster@university.edu

    ProxyRequests Off
    ProxyPreserveHost On
    AllowEncodedSlashes NoDecode

    ProxyPass        / http://127.0.0.1:3000/ nocanon
    ProxyPassReverse / http://127.0.0.1:3000/

    ErrorLog ${APACHE_LOG_DIR}/osaps-error.log
    CustomLog ${APACHE_LOG_DIR}/osaps-access.log combined
</VirtualHost>

TLS virtual host (/etc/apache2/sites-available/default-ssl.conf)

<VirtualHost *:443>
    ServerName aps.university.edu
    ServerAdmin webmaster@university.edu

    SSLEngine on
    SSLCertificateFile      /etc/ssl/certs/aps.university.edu.crt
    SSLCertificateKeyFile   /etc/ssl/private/aps.university.edu.key

    ProxyRequests Off
    ProxyPreserveHost On
    AllowEncodedSlashes NoDecode

    ProxyPass        / http://127.0.0.1:3000/ nocanon
    ProxyPassReverse / http://127.0.0.1:3000/

    ErrorLog ${APACHE_LOG_DIR}/osaps-error.log
    CustomLog ${APACHE_LOG_DIR}/osaps-access.log combined
</VirtualHost>

Reload Apache after enabling the sites:

sudo a2ensite 000-default.conf default-ssl.conf
sudo systemctl reload apache2

The critical directive is AllowEncodedSlashes NoDecode, which prevents the proxy from rewriting %2F sequences that OS-APS uses in download URLs.

Troubleshooting

Container exits with code 139 during exports

Large images expand dramatically in memory when they are rasterized. If the container exits with code 139, raise the memory limit for the Docker service or move the export job to a host with more RAM. When possible, ask authors to upload images that are already optimized (PNG for graphics, JPEG for photos).

Encoded slashes cause 404 responses behind a proxy

Some proxies decode %2F to / before forwarding the request, breaking download links. Ensure your reverse proxy forwards encoded slashes untouched. For Apache use AllowEncodedSlashes NoDecode; for NGINX set proxy_set_header X-Original-URI $request_uri; and disable rewriting of encoded characters.

Logs and support

Use docker compose logs osaps (or docker logs <container>) to inspect runtime issues. When asking for help, include the container image version, relevant environment variables, and the reverse proxy configuration you are using.

Next steps

  • Configure SMTP and authentication to match your university policies.
  • Add monitoring (Prometheus, ELK) and alerting before inviting authors.
  • Plan regular updates: pull the latest image, re-run docker compose up -d, and verify exports using your staging environment.
  • Provide a SENTRY_DSN environment variable to enable error reporting to a Sentry / Gitlab instance.
  • Provide a PrinceXML license file using PRINCE_LICENSE_PATH