Deploying a hosted instance¶
OpenDSS Designer is built as a local, single-user tool: one process, one
OpenDSS engine, no accounts. Running it on the public internet is a different
setting, so the limits that setting needs are opt-in and off by default. A
pip install behaves exactly as it always has.
Demo mode¶
opendss-designer --demo --host 0.0.0.0
# or
OPENDSS_DESIGNER_MODE=demo OPENDSS_DESIGNER_HOST=0.0.0.0 opendss-designer
Demo mode caps circuit size, request size, import size, solver queue depth and time-series cost; bounds the on-disk caches; rate-limits the outbound data fetchers; hides the interactive API docs; and enables request logging.
How many containers?¶
The OpenDSS engine is a process-wide singleton behind a single thread and a single lock. That is not incidental: the underlying library is not thread-safe, and every solve is a full rebuild. One process therefore serves one circuit at a time, and a long run blocks every other request in that process.
What that does not imply is per-visitor containers. The server keeps no session state at all — no cookies, no session ids, nothing user-scoped. Every request carries the whole circuit and the engine rebuilds from it, so any container can serve any request and no session affinity is needed.
So pick whichever fits your platform:
- One shared container — the simplest, and how the public demo runs. Solves
serialise, and the admission control returns
503withRetry-Afterwhen the queue is full rather than piling up. Fine until the demo is busy enough that visitors see those 503s. - A pool behind a load balancer — horizontal scaling works with no affinity and no sticky sessions, because of the statelessness above.
- One container per session — worth it only on a platform that scales to zero and bills per request, where the idle timeout below earns its keep.
docker build -t opendss-designer .
docker run --init -p 127.0.0.1:8721:8721 \
-e OPENDSS_DESIGNER_ALLOWED_HOSTS=demo.example.com,127.0.0.1,localhost \
-v opendss-cache:/cache \
opendss-designer
--init matters: without it SIGTERM does not reach PID 1 and the graceful
shutdown never runs.
Three things that will bite you
Keep the loopback names in OPENDSS_DESIGNER_ALLOWED_HOSTS. The value
replaces the defaults, and the image's own HEALTHCHECK requests
Host: 127.0.0.1. Drop them and the container runs but is permanently
reported unhealthy.
Publish to 127.0.0.1, not 0.0.0.0. Docker writes its own iptables
rules, which your host firewall never sees — -p 8721:8721 exposes the app
on the public interface even with everything but 80/443 closed.
Set OPENDSS_DESIGNER_IDLE_TIMEOUT_S=0 under a restart policy. The
idle shutdown exists for platforms that scale to zero. Combined with
restart: unless-stopped it just cycles the container on a timer.
And if a CDN sits in front, keep ENGINE_RESULT_TIMEOUT_S below its origin
timeout (Cloudflare's is 100s) so a slow solve returns this app's error
rather than the CDN's.
Environment variables¶
| Variable | Default | Meaning |
|---|---|---|
OPENDSS_DESIGNER_MODE |
local |
demo turns on the limits |
OPENDSS_DESIGNER_HOST |
127.0.0.1 |
bind address (0.0.0.0 in a container) |
PORT |
8721 |
bound exactly when set, with no fallback scan |
OPENDSS_DESIGNER_ALLOWED_HOSTS |
loopback names | comma-separated Host allowlist |
OPENDSS_DESIGNER_WORKDIR |
per-process temp dir | DSS side files; never share between sessions |
OPENDSS_DESIGNER_CACHE_DIR |
workdir | downloaded NREL/NSRDB data; safe to share |
OPENDSS_DESIGNER_CONFIG |
packaged copy | directory holding a custom linecodes.csv |
OPENDSS_DESIGNER_IDLE_TIMEOUT_S |
1800 in demo | exit after this long idle (0 = never) |
OPENDSS_DESIGNER_LOG_JSON |
off | one JSON object per log line |
Every limit has its own override: OPENDSS_DESIGNER_MAX_NODES, MAX_EDGES,
MAX_SHAPES, MAX_SHAPE_POINTS, MAX_TOTAL_SHAPE_POINTS, MAX_BODY_BYTES,
MAX_IMPORT_FILES, MAX_IMPORT_BYTES, MAX_QUEUED_ENGINE_CALLS,
ENGINE_RESULT_TIMEOUT_S, TIMESERIES_TIMEOUT_S, MAX_TIMESERIES_COST,
MAX_CONCURRENT_TIMESERIES, NREL_CACHE_BYTES, NSRDB_CACHE_BYTES,
SHAPE_CACHE_BYTES, MAX_OUTBOUND_BYTES, GEOCODE_PER_MINUTE,
FETCH_PER_HOUR. Setting one to 0 disables that cap.
Set OPENDSS_DESIGNER_ALLOWED_HOSTS
The default allowlist is loopback only. A reverse proxy forwards your real hostname, which is not in that list, so every request returns 400 until you set this. The server logs a warning at startup if you forget.
The shared cache¶
NREL profiles and NSRDB responses are public files keyed only by what was requested, so one volume can serve every session. Without it, each new container re-downloads 10-30 MB files. The cache is size-bounded and evicts least-recently-used entries.
Two consequences worth knowing: one visitor's NSRDB API key fetches data that later visitors read for free, and concurrent containers write to it, which is why writes go through a temp file and a rename.
Running behind a gateway: the worker contract¶
A hosted service with accounts, plans and metering is a separate program (see the hosted service plan). What this app offers such a gateway is three generic reverse-proxy features, all off unless configured, so that the gateway can run unmodified release images of this app as workers.
Per-request limits from a trusted header¶
OPENDSS_DESIGNER_TRUSTED_LIMITS_HEADER=X-OpenDSS-Limits
With that set, a request carrying the named header has its JSON applied on
top of the process settings for that request only. Keys mirror
/api/health:
{"maxNodes": 500, "maxEdges": 1000, "maxShapes": 8, "maxShapePoints": 8760,
"maxTotalShapePoints": 50000, "maxImportFiles": 5, "maxImportBytes": 262144,
"maxTimeseriesCost": 250000, "engineResultTimeoutS": 30,
"timeseriesTimeoutS": 30,
"plan": {"name": "Free", "message": "12 of 20 min used this month.",
"links": [{"label": "Upgrade", "url": "/account"}]}}
Two rules make this safe to expose to a proxy you wrote yourself:
- It only tightens. Each value is
min(process value, header value); a value of zero or below is ignored rather than read as "no limit". The environment the worker started with is the ceiling the box was sized for, and nothing a request says can raise it. - Unset means ignored. Without the variable the header is not read at all, which is the local default. Never expose a worker directly to browsers with the variable set: whoever can reach it can lower their own limits, which is harmless, and can label themselves any plan name, which is merely silly, but the intent is that only the gateway talks to workers.
A malformed header returns 400, not a silently ignored header: it is a bug in the proxy and should be loud.
Not overridable per request, because they are process-wide pools or fixed at
startup: the engine queue depth, concurrent time-series slots, fetch rate
buckets, cache sizes, the working directory, and MAX_BODY_BYTES (enforced
before routing).
Engine time¶
Responses whose handler used the OpenDSS engine carry
X-Engine-Seconds: 0.137: the time the single engine thread spent on this
call, excluding any wait in the queue. A time-series stream cannot carry it in
headers (they are sent before the run), so its final event does:
{"type": "result", "result": {...}, "engineSeconds": 6.31}, likewise on
error events and on cancelled runs. This is the number to meter.
Plan display¶
If the header includes plan, /api/health echoes it as plan and reports
the effective limits for that caller, and the banner in the UI renders the
plan name, the message, and the links (only https:// URLs and site-relative
paths are accepted). Limit messages in the Problems list say "the Free plan
is limited to 500" instead of "the public demo". The app never decides who is
on which plan; it renders what it is told.
Request ids¶
An incoming X-Request-ID matching [A-Za-z0-9._:-]{1,64} is echoed on the
response and included as requestId in every JSON log line the request
produces, including lines written from the engine thread and the time-series
worker thread.
What this app deliberately does not do¶
Adding any of this would tax the local tool that is the actual product:
- Authentication and authorization. There is no user concept.
- TLS and HSTS. Terminate TLS in front.
- Per-IP rate limiting. The built-in limits are per process, which under one-process-per-session is per visitor. Edge limiting sees the real client.
- Container orchestration, CPU and memory limits, read-only rootfs, dropped capabilities, and an absolute session TTL.
- Egress filtering. Allow only
oedi-data-lake.s3.amazonaws.com,developer.nlr.govandgeocoding-api.open-meteo.com. That, not anything in this app, is the real containment for outbound abuse.
A wrapper deployment owns all of the above.