11 Server
The server is for serving up any web services outside those of Github (e.g., website, docs and R package msens) using Docker (see the docker-compose.yml; with reverse proxying from subdomains to ports by Caddy).
The full architecture is summarized in the Software overview. This chapter documents the concrete services running on the host and how Caddy maps subdomains to container ports.
11.1 Setup
For the latest instructions on launching an Amazon instance and installing the server software, see Server Setup · MarineSensitivity/server Wiki, which is pasted below for convenience…
on AWS as EC2 instance using Docker
11.1.1 launch instance
name: msens1:
- Software Image (AMI)
Canonical, Ubuntu, 22.04 LTS, amd64 jammy image build on 2023-09-19 ami-0fc5d935ebf8bc3bc - Virtual server type (instance type)
t2.xlarge (4 vCPU, 16 GB memory)
- Firewall (security group)
New security group - Storage (volumes)
2 volume(s)- 20 GB
/server software, disposable - 60 GB
/sharefor all data, persistent and to be backed up
- 20 GB
11.1.1.1 allocate IP address
Elastic IP addresses | EC2 | us-east-1 for persistent IP address
Allocated IPv4 address:
100.25.173.0Associate Elastic IP address
11.1.2 ssh to server
pem='/Users/bbest/My Drive/private/msens_key_pair.pem'
ssh -i $pem ubuntu@msens1.marinesensitivity.org11.1.2.1 set hostname
sudo vi /etc/cloud/cloud.cfg
# preserve_hostname: true
sudo hostnamectl set-hostname msens1.marinesensitivity.org
sudo reboot11.1.2.2 mount volume
The extra volume (60 GB for /share) was added during EC2 launch instance wizard, but needs to be mounted before available for use.
df -HFilesystem Size Used Avail Use% Mounted on
/dev/root 21G 2.3G 19G 11% /
tmpfs 8.4G 0 8.4G 0% /dev/shm
tmpfs 3.4G 898k 3.4G 1% /run
tmpfs 5.3M 0 5.3M 0% /run/lock
/dev/xvda15 110M 6.4M 104M 6% /boot/efi
tmpfs 1.7G 4.1k 1.7G 1% /run/user/1000
lsblkNAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
loop0 7:0 0 24.6M 1 loop /snap/amazon-ssm-agent/7528
loop1 7:1 0 55.7M 1 loop /snap/core18/2790
loop2 7:2 0 63.5M 1 loop /snap/core20/2015
loop3 7:3 0 111.9M 1 loop /snap/lxd/24322
loop4 7:4 0 40.8M 1 loop /snap/snapd/20092
xvda 202:0 0 20G 0 disk
├─xvda1 202:1 0 19.9G 0 part /
├─xvda14 202:14 0 4M 0 part
└─xvda15 202:15 0 106M 0 part /boot/efi
xvdb 202:16 0 60G 0 disk
sudo file -s /dev/xvdb
# /dev/xvdb: dataSo no file system on /dev/xvdb yet.
sudo mkfs -t xfs /dev/xvdb
sudo mkdir /share
sudo mount /dev/xvdb /sharesudo cp /etc/fstab /etc/fstab.orig
sudo blkid
# /dev/xvdb: UUID="bc766dfb-1c42-49cf-9320-2242a2d48a2e" BLOCK_SIZE="512" TYPE="xfs"
sudo vim /etc/fstab
# UUID=bc766dfb-1c42-49cf-9320-2242a2d48a2e /share xfs defaults,nofail 0 2
df -h
sudo umount /share ; df -h
sudo mount -a ; df -h11.1.3 install docker
Following:
sudo apt-get update
#OLD: sudo apt-get install docker.io -yNEW: [[Migrate to docker compose]]
sudo systemctl start docker
sudo docker run hello-world
sudo systemctl enable docker
docker --version
# Docker version 24.0.6, build ed223bc
sudo usermod -a -G docker $(whoami)11.1.3.1 run docker compose
- /Users/bbest/My Drive/private/
msens_server_env-password.txt
sudo chown -R ubuntu:ubuntu /share
mkdir -p /share/github/MarineSensitivity
cd /share/github/MarineSensitivity
# clone server repo
git clone https://github.com/MarineSensitivity/server.git
cd server
# add password, used as $PASSWORD in docker-compose.yml
echo 'PASSWORD=******' > .env
# launch docker instances
sudo docker-compose up -d11.2 Docker compose
The Docker compose file is used to define and run multi-container Docker applications. Here is the docker-compose.yml file for the server pasted for convenience …
version: "3.9"
services:
caddy:
container_name: caddy
build: ./caddy
ports:
- 80:80
- 443:443
restart: unless-stopped
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile
- /share:/share
- /share/caddy/data:/data
- /share/caddy/config:/config
rstudio:
container_name: rstudio
build: ./rstudio
environment:
ROOT: 'true'
# DEFAULT_USER, not USER: rocker's /rocker_scripts/init_userconf.sh opens with
# DEFAULT_USER=${DEFAULT_USER:-rstudio}
# USER=${DEFAULT_USER}
# so a `USER:` set here is CLOBBERED on line 6 before it is ever read. This
# said `USER: admin`, so no admin account was ever created — only `rstudio` —
# and logging into rstudio.marinesensitivity.org as admin failed with
# "Incorrect or invalid username/password" while the password was perfectly
# correct. Takes effect on the next container recreate; until then the login
# is `rstudio`.
DEFAULT_USER: admin
PASSWORD: ${PASSWORD}
ADD: shiny
# usage-log Sheet endpoint (Apps Script /exec) for the Shiny apps'
# msens::ga_js() beacon. Set MSENS_LOG_URL in the server .env alongside
# PASSWORD; if unset the Sheet leg is a silent no-op and only GA4 receives
# events. The apps hold no credential — the endpoint is the whole secret,
# so it belongs in .env (untracked), never in the app source.
MSENS_LOG_URL: ${MSENS_LOG_URL:-}
ports:
- 8787:8787 # rstudio
- 3838:3838 # shiny
restart: unless-stopped
volumes:
- /share:/share
- /share/shiny_apps:/srv/shiny-server
# plumber: DuckDB-backed custom API (postgres stack retired 2026-06). Serves
# the OBIS H3 summary endpoint /h3 (obisindicators::obis_h3t_sql) from the same
# store as the h3t tile service. Caddy already routes
# api.marinesensitivity.org -> plumber:8888. No postgis dependency: the
# postgres-backed endpoints degrade (see api/plumber.R lazy `con`).
plumber:
container_name: plumber
build: ./plumber
environment:
MSENS_OBIS_DUCKDB: /share/data/obis/obis_h3.duckdb
ports:
- 8888:8888 # api
volumes:
- /share:/share
restart: unless-stopped
# --- postgres stack retired (2026-06) to free resources for OBIS h3 build ---
# plumber:
# container_name: plumber
# build: ./plumber
# ports:
# - 8888:8888 # api
# restart: unless-stopped
# volumes:
# - /share:/share
# depends_on:
# - postgis
# postgis:
# container_name: postgis
# image: postgis/postgis:latest
# environment:
# POSTGRES_DB: msens
# POSTGRES_USER: admin
# POSTGRES_PASSWORD: ${PASSWORD}
# ANON_PASSWORD: ${ANON_PASSWORD}
# PGDATA: /share/postgis/data
# volumes:
# - ./postgis/init.sh:/docker-entrypoint-initdb.d/init.sh
# - /share:/share
# - /share/postgis:/var/lib/postgresql
# restart: unless-stopped
# healthcheck:
# test: 'exit 0'
# ports:
# - 5432:5432
# pgadmin:
# container_name: pgadmin
# image: dpage/pgadmin4:8.14
# restart: always
# environment:
# PGADMIN_DEFAULT_EMAIL: ben@ecoquants.com
# PGADMIN_DEFAULT_PASSWORD: ${PASSWORD}
# PGADMIN_LISTEN_PORT: 8088
# ports:
# - 8088:8088
# volumes:
# - /share/pgadmin:/var/lib/pgadmin
# depends_on:
# - postgis
# pgbkups:
# container_name: pgbkups
# image: prodrigestivill/postgres-backup-local
# restart: always
# user: postgres:postgres
# volumes:
# - /share/postgis_backups:/backups
# links:
# - postgis
# depends_on:
# - postgis
# environment:
# - POSTGRES_HOST=postgis
# - POSTGRES_DB=msens
# - POSTGRES_USER=admin
# - POSTGRES_PASSWORD=${PASSWORD}
# - POSTGRES_EXTRA_OPTS=-Z6 --blobs
# - SCHEDULE=@daily
# - HEALTHCHECK_PORT=8088
# - BACKUP_KEEP_DAYS=2
# - BACKUP_KEEP_WEEKS=1
# - BACKUP_KEEP_MONTHS=2
# tile:
# container_name: tile
# environment:
# DATABASE_URL: 'postgresql://admin:${PASSWORD}@postgis:5432/msens'
# image: pramsey/pg_tileserv:latest
# depends_on:
# - postgis
# ports:
# - 7800:7800
# tilecache:
# container_name: tilecache
# image: varnish:latest
# volumes:
# - /share:/share
# ports:
# - 6081:6081
# environment:
# VARNISH_BACKEND_HOST: tile
# VARNISH_BACKEND_PORT: 7800
# VARNISH_HTTP_PORT: 6081
# restart: always
# depends_on:
# - "tile"
# --- end postgres stack ---
# rest:
# container_name: rest
# environment:
# PGRST_DB_URI: 'postgresql://anon:${ANON_PASSWORD}@postgis:5432/msens'
# PGRST_OPENAPI_SERVER_PROXY_URI: http://127.0.0.1:3000
# PGRST_DB_ANON_ROLE: anon
# image: postgrest/postgrest
# depends_on:
# - postgis
# ports:
# - "3000:3000"
# swagger:
# container_name: swagger
# image: swaggerapi/swagger-ui
# depends_on:
# - rest
# ports:
# - "8080:8080"
# expose:
# - "8080"
# environment:
# API_URL: https://rest.MarineSensitivity.org/
titiler:
container_name: titiler
build: ./titiler
environment:
PORT: 8000
# WORKERS_PER_CORE: 1
MSENS_CELLID_COG: /share/data/derived/r_cellid.tif
MSENS_DUCKDB: /share/data/big/latest/sdm.duckdb
MSENS_MAX_ROWS: "1000000"
MSENS_LRU_SIZE: "128"
volumes:
- /share:/share
ports:
- "8000:8000"
restart: always
# v8 parallel serving: a tiny view-only DuckDB whose model_cell is a VIEW over the
# partitioned Parquet on S3 (marine-atlas/v8), rendered on the global [-180,180] cell COG.
# v7 titiler above stays fully live (A/B). serve.duckdb is KB — no multi-GB rsync.
titiler-v8:
container_name: titiler-v8
build: ./titiler
environment:
PORT: 8000
MSENS_CELLID_COG: /share/data/derived/r_cellid_global.tif
MSENS_DUCKDB: /share/data/big/v8/serve.duckdb
MSENS_MAX_ROWS: "1000000"
MSENS_LRU_SIZE: "128"
volumes:
- /share:/share
ports:
- "8001:8000"
restart: always
# stac-api: a SEARCHABLE STAC API with one Item per model, complementing (not
# replacing) the static catalog at file.marinesensitivity.org/stac.
#
# The static catalog is dataset-level: one Item per collection whose assets are
# S3 PREFIXES (…/native/am_native/, which 403s) and whose bbox is the whole
# dataset envelope — so it can answer neither "the asset for this model" nor
# "which models cover this area". This serves 25,643 per-model Items with real
# asset hrefs and real footprints.
#
# Backend reads stac-geoparquet via DuckDB — the same Parquet-on-DuckDB shape as
# the rest of this stack. Items and collection.json come from the tree that
# workflows/publish_stac_api.qmd builds and rsyncs; PARQUET_URLS_JSON (collection
# id -> parquet path) is passed by that notebook from parquet_urls.json, so
# adding a dataset needs no edit here.
#
# Pinned to an exact upstream commit built straight from GitHub — the project is
# marked EXPERIMENTAL by both its repo and the stac-fastapi docs (pgstac and
# Elasticsearch are the production-ready backends), so a moving default branch
# is not something to serve from. Treat this as a trial surface, not load-bearing.
stac-api:
container_name: stac-api
build: ./stac-api # upstream pinned + ST_Intersects(WKB) patch; see its Dockerfile
environment:
APP_HOST: 0.0.0.0
APP_PORT: "8084"
ENVIRONMENT: production
WEB_CONCURRENCY: "4"
BACKEND: duckdb
STAC_FILE_PATH: /app/stac_collections
PARQUET_URLS_JSON: ${PARQUET_URLS_JSON}
volumes:
# read-only: the API must never mutate the published catalog
- /share/data/derived/stac-api/v8:/app/stac_collections:ro
ports:
- "8085:8084"
restart: unless-stopped
command: bash -c "python -m stac_fastapi.duckdb.app"
titilecache:
container_name: titilecache
image: varnish:latest # 7.4.2 # last updated: 2023-12-26
volumes:
- /share:/share
- "./varnish/titiler.vcl:/etc/varnish/default.vcl:ro"
ports:
- 6082:6082
environment:
VARNISH_BACKEND_HOST: titiler
VARNISH_BACKEND_PORT: 8000
VARNISH_HTTP_PORT: 6082
command: "-p default_keep=604800" # 7d, matches vcl TTL
restart: always
depends_on:
- "titiler"
# h3t: OBIS biodiversity-by-hex tile factory (DuckDB SELECT -> h3j JSON tiles)
h3t:
container_name: h3t
build: ./h3t
environment:
H3T_DBS: "obis:/share/data/obis/obis_h3.duckdb"
H3T_DEFAULT_DB: obis
H3T_MAX_ROWS: "50000"
# common filtered maps now hit precomputed idx_h3 / idx_h3_taxon layers;
# these caps are the stopgap for the remaining live occ_h3 paths (year
# filters, multi-value / fine-rank taxa). raise the timeout above the
# app-side client timeout in apps/h3-db/app.R.
H3T_STMT_TIMEOUT_MS: "8000"
H3T_THREADS: "4" # cap serving CPU (don't peg all cores)
H3T_MEMORY_LIMIT: "6GB" # cap serving RAM (occ_h3 rollups can spill)
volumes:
- /share:/share
ports:
- "8889:8889"
restart: always
h3tcache:
container_name: h3tcache
image: varnish:latest # 7.4.2 # last updated: 2023-12-26
volumes:
- /share:/share
- "./varnish/h3t.vcl:/etc/varnish/default.vcl:ro"
ports:
- 6083:6083
environment:
VARNISH_BACKEND_HOST: h3t
VARNISH_BACKEND_PORT: 8889
VARNISH_HTTP_PORT: 6083
command: "-p default_keep=604800" # 7d, matches vcl TTL
restart: always
depends_on:
- "h3t"11.3 DNS
The domain name server (DNS) records are managed by SquareSpace. The subdomains point to the server on Amazon at 100.25.173.0, whereas the main website is hosted by Github servers, per Managing a custom domain for your GitHub Pages site - GitHub Docs.
| Host | Type | Data |
|---|---|---|
| @ | A | 185.199.111.153 |
| @ | A | 185.199.110.153 |
| @ | A | 185.199.109.153 |
| @ | A | 185.199.108.153 |
| api | A | 100.25.173.0 |
| app | A | 100.25.173.0 |
| file | A | 100.25.173.0 |
| msens1 | A | 100.25.173.0 |
| pgadmin | A | 100.25.173.0 |
| pmtiles | A | 100.25.173.0 |
| rstudio | A | 100.25.173.0 |
| shiny | A | 100.25.173.0 |
| tile | A | 100.25.173.0 |
| tilecache | A | 100.25.173.0 |
| titiler | A | 100.25.173.0 |
| titilecache | A | 100.25.173.0 |
| www | CNAME | marinesensitivity.org |
11.4 Caddyfile
The Caddyfile parameterizes the reverse proxying between the external subdomains and the Docker’s internal ports. Here is the Caddyfile pasted for convenience …
# docker exec caddy caddy reload --config /etc/caddy/Caddyfile
{
order pmtiles_proxy before file_server
}
(cors) {
@origin header Origin *
header @origin {
Access-Control-Allow-Origin "*"
Access-Control-Request-Method GET
}
}
api.marinesensitivity.org {
reverse_proxy plumber:8888
}
file.marinesensitivity.org {
import cors
# raw pmtiles for client-side PMTiles protocol (add_pmtiles_source)
handle_path /pmtiles/* {
root * /share/data/derived/pmtiles
file_server browse
}
# legacy versioned pmtiles routes (backwards compat for old apps)
handle_path /pmtiles/v4/* {
root * /share/data/derived/v4/pmtiles
file_server browse
}
handle_path /pmtiles/v3/* {
root * /share/data/derived/v3/pmtiles
file_server browse
}
# stac catalog (sdm extension) — static JSON tree
handle_path /stac/* {
root * /share/data/derived/stac
file_server browse
}
# versioned derived data (geoparquet, cogs, gpkg) referenced by stac assets
handle_path /derived/* {
root * /share/data/derived
file_server browse
}
# force reports to download (especially HTML which otherwise renders inline)
handle_path /reports/* {
root * /share/public/reports
header Content-Disposition "attachment"
file_server
}
handle {
root * /share/public
file_server browse {
precompressed zstd br gzip
}
}
}
# Browsable front door for the PUBLIC S3 bucket.
#
# Why this exists: S3 serves OBJECTS, not directories. A URL ending in "/" 404s
# unless an object literally has that key, and anonymous ListBucket is DENIED on
# this bucket (403 AccessDenied), so a browser cannot enumerate anything. The
# workflows notebook publish_storage_index.qmd generates a real index.html per
# directory; this vhost rewrites folder URLs to them:
#
# storage.marinesensitivity.org/marine-atlas/v8/ -> .../v8/index.html
# storage.marinesensitivity.org/marine-atlas/latest.txt -> the object
#
# Named "storage" rather than "s3" so the URL survives a move to another
# provider.
storage.marinesensitivity.org {
encode zstd gzip
log {
output file /share/logs/caddy/storage.log {
roll_size 64MiB
roll_keep 14
roll_keep_for 2160h
}
format json
}
# robots.txt inline, BEFORE the bucket handler, so it cannot be lost by an
# index regeneration. The catalog pages are welcome in search results; the
# objects behind them are not. Every byte is PROXIED through this server
# (reverse_proxy, not a redirect), and the tree is 18 GB across ~90,000
# objects, so a crawl would be billed as VM egress.
handle /robots.txt {
header Content-Type "text/plain; charset=utf-8"
respond `# Catalog pages: crawl away. Data objects: please do not.
User-agent: *
Crawl-delay: 10
Disallow: /*.tif$
Disallow: /*.parquet$
Disallow: /*.duckdb$
Disallow: /*.pmtiles$
Disallow: /*.gz$
Disallow: /*.zip$
` 200
}
# ONLY the published atlas. The same bucket holds backups/ -- including a
# multi-GB database dump -- which is technically public but must not be
# advertised or reachable here, even by exact path. This allow-list is the
# server-side half of the restriction the index generator applies.
@atlas path_regexp atlas ^/marine-atlas(/|$)
handle @atlas {
# folder URL -> the index.html object standing in for a listing
@dir path_regexp dir ^(.*)/$
rewrite @dir {re.dir.1}/index.html
reverse_proxy https://s3.us-east-1.amazonaws.com {
header_up Host s3.us-east-1.amazonaws.com
rewrite /oceanmetrics.io-public{uri}
}
}
# the bare host lands on the atlas index
redir / /marine-atlas/ 302
# anything else: refuse, and say where to go
handle {
header Content-Type "text/plain; charset=utf-8"
respond `Not found.
Browsable: /marine-atlas/
Start here: https://storage.marinesensitivity.org/marine-atlas/
` 404
}
}
pgadmin.marinesensitivity.org {
reverse_proxy pgadmin:8088
}
# rest.marinesensitivity.org {
# reverse_proxy rest:3000
# }
# searchable STAC API (one Item per model) — complements the STATIC catalog served
# from file.marinesensitivity.org/stac, which stays exactly as it is.
stac-api.marinesensitivity.org {
reverse_proxy stac-api:8084
}
rstudio.marinesensitivity.org {
reverse_proxy rstudio:8787
}
shiny.marinesensitivity.org {
reverse_proxy rstudio:3838
}
app.marinesensitivity.org {
reverse_proxy rstudio:3838
}
shiny.oceanmetrics.io {
reverse_proxy rstudio:3838
}
# swagger.marinesensitivity.org {
# reverse_proxy swagger:8080
# }
tile.marinesensitivity.org {
reverse_proxy tile:7800
}
tilecache.marinesensitivity.org {
reverse_proxy tilecache:6081
}
titiler.marinesensitivity.org {
reverse_proxy titiler:8000
}
titiler-v8.marinesensitivity.org {
reverse_proxy titiler-v8:8000
}
titilecache.marinesensitivity.org {
reverse_proxy titilecache:6082
}
h3t.marinesensitivity.org {
reverse_proxy h3t:8889
}
h3tcache.marinesensitivity.org {
reverse_proxy h3tcache:6083
}
pmtiles.marinesensitivity.org {
import cors
# server-side tile decoding: ZXY + TileJSON (for QGIS, leaflet, etc.)
handle_path /tiles/* {
pmtiles_proxy {
bucket file:///share/data/derived/v4/pmtiles
# note: v3 pmtiles also available at /share/data/derived/v3/pmtiles
cache_size 256
public_url https://pmtiles.marinesensitivity.org/tiles
}
}
}
11.5 Services
The server is running the following services. Subdomains with 🔒 are internal (admin / development); others are public.
11.5.1 App + data plane
app / shiny
interactive Shiny applications (scores,species, …) — proxied to therstudiocontainer’s Shiny Server on internal port 3838

Shiny Server docsfile
static file server for PMTiles archives, report downloads, reference GeoPackages — served by Caddy’sfile_serverdirective directly from the/sharebind-mount. This replaces the oldpg_tileservvector-tile path for Program Areas, Ecoregions, Planning Areas, protractions, blocks and aliquots; each layer is a single.pmtilesfile that the browser range-reads for only the tiles it needs.titiler + titilecache 🔒🟢
custom msens TiTiler factory fronted by a Varnish cache.
The factory exposes/msens/*routes that execute a validated SELECT against DuckDB, look the result up against a pre-baked cell-id COG, and return on-the-fly colorized PNG tiles (see Chapter 14).titilecachekeys on the full URL (including base64 SQL) and caches each tile for 7 days withdefault_keep=604800; the custom VCL (server/varnish/titiler.vcl) normalizes query-param order viastd.querysort()so semantically-identical URLs share a cache entry, strips cookies / authorization (the factory is stateless), and adds anX-Cache: HIT | MISSdebug header.api
custom RplumberAPI for programmatic access to DuckDB — see Chapter 14

11.5.2 Admin
rstudio 🔒
integrated development environment (IDE) to code and debug directly on the server.

Posit RStudio Serverpgadmin 🔒
PostgreSQL database administration interface — used for legacy apps that still connect to PostgreSQL (see below).

pgAdmin
11.5.3 Legacy (carried for backward-compat; not in the critical path)
As of 2026-04, the new raster tile path (titiler + titilecache) and PMTiles-via-file replace what was previously handled by PostgreSQL + pg_tileserv. The authoritative store is now DuckDB (/share/data/big/latest/sdm.duckdb). The following services remain in docker-compose.yml only for backward compatibility with a small set of pre-2026 apps (indicators, bird_hotspots) that still read from PostgreSQL. New app code should target DuckDB + the TiTiler factory + PMTiles.
- postgis (
postgis/postgis:latest, internal 5432)
PostgreSQL + PostGIS — formerly the primary spatial database; now read by legacy apps only. - tile (
pramsey/pg_tileserv:latest, internal 7800) + tilecache (Varnish, 6081)
vector tiles from PostGIS tables — superseded by static PMTiles served by Caddy. - rest and swagger — PostgREST + Swagger UI, both commented out in
docker-compose.yml.