A uv-built (no conda), salt-free, nginx-free Tethys Platform base image. It contains the Tethys platform + framework/serving dependencies and a small set of generic init/serve scripts — but no apps. Portal images build from it and add their own apps and config.
Note on the
/opt/condalayout: this image has no conda —/opt/conda/envs/tethysis a plainuv venv. TheCONDA_*env vars + path only exist because upstream Tethys hard-codes them; it's a compatibility shim, not conda.
- The image builds and serves. Nothing else. The serving entrypoint (
serve.sh) only renders config + injects secrets, then runs the server. It never migrates the DB, syncs stores, collects/publishes static, or discovers hosts. - Provisioning is pipeline work, not image work. DB migrations, persistent-store creation +
syncstores, static publishing, and superuser/branding are run by the deploy pipeline / CI (a job or step that invokes the scripts below), out of band and once — not on container start. The scripts live in the image as tools the pipeline calls. - Deployment config is declarative, not discovered. Hosts, storage backends, proxy/SSL headers,
etc. belong in a portal's own
portal_config.yml(or its deploy inputs). The base does not sniff the environment to decide behavior. - No behavior flags. No
RUN_STATIC/RUN_STORE_SETUP/ host-discovery switches. If the image doesn't do a thing, there's nothing to toggle. The one derived behavior is the server mode, and it follows an existing declarative setting (see below), not a dedicated flag. - Dev server follows
DEBUG.serve.shreadssettings.DEBUGfrom the renderedportal_config.yml:DEBUG: true→ the Django dev server (tethys manage start, serves/static/locally, no CDN); otherwise the production ASGI server (uvicorn, or gunicorn ifSERVER=gunicorn). SetDEBUGdeclaratively in the config, or override it per-run with theTETHYS_DEBUGenv (useful when the config is baked into the image, e.g. docker-compose dev) —portal-config.shapplies it.
Storage, proxy/SSL, cursor mode, and deployment-specific host discovery are portal-owned (declared in each portal's config or a
portal-config.dhook).portal-config.shkeeps only the genericPORTAL_ALLOWED_HOSTSenv merge (k8s supplies the pod IP via afieldRef). Static publishing is a standalonepublish-static.shstep the pipeline runs — noRUN_STATIC/RUN_STORE_SETUPflags.
| Tag | What it is | Use |
|---|---|---|
ghcr.io/aquaveo/tethys-uvx:builder |
toolchain (uv + Node + gcc) + venv + Tethys + framework deps | a portal's build stage |
ghcr.io/aquaveo/tethys-uvx:runtime-base |
slim runtime without the venv (libs + user + scripts) | a portal's runtime stage |
ghcr.io/aquaveo/tethys-uvx:runtime |
runtime-base + the no-apps venv |
a runnable no-apps Tethys |
Each also gets <target>-<short-sha> and, on a git tag, <target>-<tag>. Pin a specific tag in
downstream portals so a base change can't silently break them.
- In (base): Tethys platform, Django, channels, uvicorn/gunicorn, DRF, psycopg2-binary, the
init/serve scripts, a generic
portal_config.ymlskeleton at/config/portal_config.yml. - Out (portal layer): the scientific/geo stack, tethysdash/GEOGLOWS/other apps, plugins, the
portal-specific
portal_config.yml/ branding / app settings, and storage — a portal that wants S3/object storage brings its owndjango-storages+boto3+ storage backend module and declaresSTORAGES(seeportal-config.d). The base configures no storage.
FROM ghcr.io/aquaveo/tethys-uvx:builder AS builder
# npm build + `uv pip install` your apps into ${VIRTUAL_ENV}
FROM ghcr.io/aquaveo/tethys-uvx:runtime-base
COPY --from=builder /opt/python /opt/python
COPY --from=builder /opt/conda /opt/conda # venv with your apps
COPY --chown=1000:1000 conf/portal_config.yml /config/portal_config.yml # your config/branding
# CMD (serve.sh) is inherited from the baseTETHYS_DB_ENGINE selects the backend; the scripts branch on it via db-env.sh:
| Engine | TETHYS_DB_ENGINE |
Notes |
|---|---|---|
| postgres (default) | django.db.backends.postgresql |
needs TETHYS_DB_HOST/PORT/USERNAME/PASSWORD; supports poolers + PostGIS persistent stores |
| sqlite | django.db.backends.sqlite3 |
one file, no server — ideal for a single-container local/dev portal (the DB-role wait is skipped) |
For sqlite the DB file is TETHYS_DB_NAME (if absolute) or ${TETHYS_PERSIST}/tethys_platform.sqlite
— keep TETHYS_PERSIST on a mounted volume. Persistent stores are Postgres-only.
Comments in the scripts are intentionally terse; this is the reference.
Serving (runs in the web container):
serve.sh— the imageCMD. Runsportal-config.sh, then serves:DEBUG: true→ dev server; else uvicorn (or gunicorn viaSERVER=gunicorn). Does no provisioning.portal-config.sh— copies/config/portal_config.ymlintoTETHYS_HOME, merges thePORTAL_ALLOWED_HOSTS+TETHYS_DEBUGenv into settings (ALLOWED_HOSTS/CSRF/DEBUG), injectsSECRET_KEY+ DB connection, then runs any/opt/portal/portal-config.d/*.sh(portal-owned config, e.g.STORAGES, host discovery, OAuth secrets). Idempotent; no DB writes.db-env.sh— sourced helper; setsDB_IS_SERVER/SQLITE_PATHfromTETHYS_DB_ENGINE.
Provisioning (invoked by the deploy pipeline / an init job, NOT the web container):
provision.sh— the one-shot provision verb (run once per release, never on web-pod creation). In order: wait for the DB role →portal-config→tethys db migrate→ create the PostGIS persistent-store service (ifTETHYS_PS_CONNECTION) →publish-static(skipped whenDEBUGis true, since dev serves static from runserver) → superuser +tethys site -f→ portalinit.dhooks.publish-static.sh— collect (incl. tethysdash plugin static, if present) →collectstatic(uploads to S3 whenSTORAGES.staticfilesis S3). Called byprovision.sh.
/opt/portal/portal-config.d/*.sh— run byportal-config.sh(so in both the provision and web containers), for portal-owned config injection that needs deploy-time env, e.g.STORAGESfrom a bucket var.COPY conf/portal-config.d/ /opt/portal/portal-config.d/./opt/portal/init.d/*.sh— run byprovision.sh(provisioning only), after migrations/ branding, for one-off setup (proxy apps, seed data).COPY init.d/ /opt/portal/init.d/.
Each script is idempotent; both dirs are optional (skipped if absent).
portal_config.yml— generic skeleton shipped at/config/portal_config.yml; a portal image overwrites it with its own (DB, branding, app settings,STORAGES,SECURE_PROXY_SSL_HEADER).
Storage is portal-owned: PYTHONPATH=/opt/portal is available for a portal to drop in a storage
backend module (e.g. a leading-slash-tolerant PortalStaticS3Storage), but the base ships neither the
module nor django-storages/boto3.
.github/workflows/build.yml builds the targets and pushes to GHCR on push to main / tags. The
first publish makes a private package — switch it to public once in the package settings.