Skip to content

Instantly share code, notes, and snippets.

@DonRichards
Created May 20, 2026 17:58
Show Gist options
  • Select an option

  • Save DonRichards/58a75ec1d0e89e8850afb172332254d9 to your computer and use it in GitHub Desktop.

Select an option

Save DonRichards/58a75ec1d0e89e8850afb172332254d9 to your computer and use it in GitHub Desktop.
Sync Dataverse Server to Docker Container
APP_IMAGE=gdcc/dataverse:6.7.1-noble
CONFIG_BAKER_IMAGE=gdcc/configbaker:6.7.1-noble
POSTGRES_VERSION=17
DATAVERSE_DB_USER=dataverse
SOLR_VERSION=9.8.0
SKIP_DEPLOY=0
# Path inside the container (matches /dv volume mount)
STORAGE_DIR=/dv
# Branding sync (scripts/dev/sync-branding-from-live.sh)
LIVE_URL=https://dataverse.example.com
SSH_HOST=user@dataverse.example.com
LIVE_API_TOKEN=
LOCAL_API_TOKEN=
# DV_DATA_DIR=
# SYNC_DIR=
# ROOT_ALIAS=
# REMOTE_DATAVERSE_URL=
REMOTE_BRANDING_DIR=/var/www/dataverse/branding
REMOTE_DOCROOT=/usr/local/payara/glassfish/domains/domain1/docroot

Dataverse local dev + live branding sync

Files in this gist are meant to sit at the root of a Dataverse git clone (same layout as IQSS/dataverse). They do not replace the whole repo—only add or override a few paths.

Where each file goes

Gist file Path in your clone
.env .env (repository root)
docker-compose-dev.yml docker-compose-dev.yml
docker-compose-prebuilt.yml docker-compose-prebuilt.yml
sync-branding-from-live.sh scripts/dev/sync-branding-from-live.sh
sync-branding-from-live.md scripts/dev/sync-branding-from-live.md

After copying:

chmod +x scripts/dev/sync-branding-from-live.sh

Keep .env out of git (add to .gitignore if needed). Do not commit API tokens or production credentials.


Prerequisites

  • A Dataverse source tree (or fork) with normal dev dependencies: Docker, Docker Compose v2, Git.
  • For prebuilt images: no local Maven/WAR build required (see compose section below).
  • For branding sync: on your laptop: curl, jq, ssh, scp.
  • SSH access to the production host (SSH_HOST) so you can run read-only commands there (admin settings are usually only reachable as curl http://127.0.0.1:8080/... on the server).
  • HTTPS access from your laptop to the public live site (LIVE_URL).

.env (repository root)

Docker Compose automatically loads .env from the project directory when you run docker compose. The branding script also reads selected keys from the same file (it does not override variables already exported in your shell).

Typical variables

# --- Docker Compose (dev stack) ---
# Image for dev_dataverse (example; use your registry/tag)
APP_IMAGE=gdcc/dataverse:6.7.1
# Other compose vars your team uses (DB user, SKIP_DEPLOY, etc.)

# --- Branding sync (scripts/dev/sync-branding-from-live.sh) ---
LIVE_URL=https://your-production-dataverse.example.edu
SSH_HOST=you@prod-server.example.edu

# Optional
LOCAL_URL=http://localhost:8080
# LOCAL_API_TOKEN=...   # local admin API token (root name / theme PUT)
# LIVE_API_TOKEN=...    # only if using --with-theme against live

# Optional overrides (non-JHU installs)
# REMOTE_DOCROOT=/usr/local/payara/glassfish/domains/domain1/docroot
# REMOTE_EXTRA_FILES=/remote/path/logo.png:docroot/images/logo.png
Variable Used by Purpose
APP_IMAGE Docker Compose dev_dataverse container image
LIVE_URL Sync script Public production URL (HTTPS pull)
SSH_HOST Sync script user@host for admin API + scp
LOCAL_URL Sync script Local Dataverse (default http://localhost:8080)
LOCAL_API_TOKEN Sync script Optional: root display name (--closer), root theme
LIVE_API_TOKEN Sync script Optional: --with-theme on live

Full list of branding-related variables, flags, and troubleshooting: see scripts/dev/sync-branding-from-live.md in the gist.


Start local Dataverse (Docker)

From the repository root.

Option A — Prebuilt image (recommended if you are not building the WAR)

docker-compose-prebuilt.yml removes the mount of ./target/dataverse over Payara’s deployment dir. Without that override, an empty target/dataverse folder makes Payara fail with “Archive type … was not recognized”.

docker compose -f docker-compose-dev.yml -f docker-compose-prebuilt.yml up -d

Data and branding files live on the host at:

./docker-dev-volumes/app/data → mounted in the container as /dv

That bind mount is what makes synced branding visible to Payara without manual copies.

Option B — Local WAR build

If you build and deploy from source, use only the main compose file (and ensure ./target/dataverse contains a valid deployment):

docker compose -f docker-compose-dev.yml up -d

Open: http://localhost:8080

Stop:

docker compose -f docker-compose-dev.yml down
# add -f docker-compose-prebuilt.yml if you used the prebuilt override

Sync look-and-feel from production (read-only)

The sync script:

  • Reads production only (SSH + HTTPS GET / read-only scp). It never writes to production.
  • Writes only to your laptop: staging under docker-dev-volumes/app/data/branding-sync/, then local files + admin settings on http://localhost:8080.
  • The container does not call production; your machine pulls data and updates the local install.

From the repository root, with LIVE_URL and SSH_HOST set in .env (or exported):

# JHU-style / max fidelity (docroot CSS, fonts, logo, URL rewrites, extra settings)
./scripts/dev/sync-branding-from-live.sh --closer --yes

# Standard installation branding only
./scripts/dev/sync-branding-from-live.sh --yes

# Fetch only, inspect, apply later
./scripts/dev/sync-branding-from-live.sh --no-apply
./scripts/dev/sync-branding-from-live.sh --apply-only --yes

Help:

./scripts/dev/sync-branding-from-live.sh --help

Verify

curl -s http://localhost:8080/api/admin/settings/:HeaderCustomizationFile
# Expect something like /dv/branding/custom-header.html

docker exec dev_dataverse test -r /dv/branding/custom-header.html && echo OK

Hard-refresh the browser. Compare with LIVE_URL.

If settings exist but the UI still looks stock, the container may not see the host bind mount. The script tries docker cp automatically; otherwise:

docker cp docker-dev-volumes/app/data/branding dev_dataverse:/dv/branding
docker cp docker-dev-volumes/app/data/docroot/. dev_dataverse:/dv/docroot/

Or recreate the stack from repo root so ./docker-dev-volumes/app/data:/dv is wired correctly.


What gets synced (summary)

  • Whitelisted installation settings: custom homepage/header/footer/CSS, navbar URLs, footer copyright, installation name, languages, etc.
  • Files under /var/www/dataverse/branding/ (via SSH) and, with --closer / --with-docroot, Payara docroot assets and Apache static paths (e.g. navbar logo).
  • Not synced: auth, DOI, email, datasets, bulk admin settings, or a full database dump.

Fork-only DB keys (e.g. JHU :instance*) are mapped to stock 6.7.1 keys where possible with --closer; some production UI behavior still requires a matching fork or manual work.

Details: scripts/dev/sync-branding-from-live.md.


Security

  • Production: read-only (GET, scp/test -r for files). No PUT/POST/DELETE to live.
  • Store tokens only in .env or your shell, not in the gist and not in git.
  • Treat .env as local secrets.

Quick reference

Task Command
Up (prebuilt) docker compose -f docker-compose-dev.yml -f docker-compose-prebuilt.yml up -d
Sync branding ./scripts/dev/sync-branding-from-live.sh --closer --yes
Full docs scripts/dev/sync-branding-from-live.md
version: "2.4"
services:
dev_dataverse:
container_name: "dev_dataverse"
hostname: dataverse
image: ${APP_IMAGE}
restart: on-failure
user: payara
environment:
DATAVERSE_DB_HOST: postgres
DATAVERSE_DB_PASSWORD: secret
DATAVERSE_DB_USER: ${DATAVERSE_DB_USER}
ENABLE_JDWP: "1"
ENABLE_RELOAD: "1"
SKIP_DEPLOY: "${SKIP_DEPLOY}"
DATAVERSE_JSF_REFRESH_PERIOD: "1"
DATAVERSE_FEATURE_API_BEARER_AUTH: "1"
DATAVERSE_FEATURE_INDEX_HARVESTED_METADATA_SOURCE: "1"
DATAVERSE_FEATURE_API_BEARER_AUTH_PROVIDE_MISSING_CLAIMS: "1"
DATAVERSE_MAIL_SYSTEM_EMAIL: "dataverse@localhost"
DATAVERSE_MAIL_MTA_HOST: "smtp"
DATAVERSE_AUTH_OIDC_ENABLED: "1"
DATAVERSE_AUTH_OIDC_HIDDEN_JSF: "1"
DATAVERSE_AUTH_OIDC_CLIENT_ID: test
DATAVERSE_AUTH_OIDC_CLIENT_SECRET: 94XHrfNRwXsjqTqApRrwWmhDLDHpIYV8
DATAVERSE_AUTH_OIDC_AUTH_SERVER_URL: http://keycloak.mydomain.com:8090/realms/test
DATAVERSE_SPI_EXPORTERS_DIRECTORY: "/dv/exporters"
# These two oai settings are here to get HarvestingServerIT to pass
dataverse_oai_server_maxidentifiers: "2"
dataverse_oai_server_maxrecords: "2"
JVM_ARGS: -Ddataverse.files.storage-driver-id=file1
-Ddataverse.files.file1.type=file
-Ddataverse.files.file1.label=Filesystem
-Ddataverse.files.file1.directory=${STORAGE_DIR}/store
-Ddataverse.files.localstack1.type=s3
-Ddataverse.files.localstack1.label=LocalStack
-Ddataverse.files.localstack1.custom-endpoint-url=http://localstack:4566
-Ddataverse.files.localstack1.custom-endpoint-region=us-east-2
-Ddataverse.files.localstack1.bucket-name=mybucket
-Ddataverse.files.localstack1.path-style-access=true
-Ddataverse.files.localstack1.upload-redirect=true
-Ddataverse.files.localstack1.download-redirect=true
-Ddataverse.files.localstack1.access-key=default
-Ddataverse.files.localstack1.secret-key=default
-Ddataverse.files.minio1.type=s3
-Ddataverse.files.minio1.label=MinIO
-Ddataverse.files.minio1.custom-endpoint-url=http://minio:9000
-Ddataverse.files.minio1.custom-endpoint-region=us-east-1
-Ddataverse.files.minio1.bucket-name=mybucket
-Ddataverse.files.minio1.path-style-access=true
-Ddataverse.files.minio1.upload-redirect=false
-Ddataverse.files.minio1.download-redirect=false
-Ddataverse.files.minio1.access-key=4cc355_k3y
-Ddataverse.files.minio1.secret-key=s3cr3t_4cc355_k3y
-Ddataverse.pid.providers=fake
-Ddataverse.pid.default-provider=fake
-Ddataverse.pid.fake.type=FAKE
-Ddataverse.pid.fake.label=FakeDOIProvider
-Ddataverse.pid.fake.authority=10.5072
-Ddataverse.pid.fake.shoulder=FK2/
#-Ddataverse.files.guestbook-at-request=true
#-Ddataverse.lang.directory=/dv/lang
ports:
- "8080:8080" # HTTP (Dataverse Application)
- "4949:4848" # HTTPS (Payara Admin Console)
- "9009:9009" # JDWP
- "8686:8686" # JMX
networks:
- dataverse
depends_on:
- dev_postgres
- dev_solr
- dev_dv_initializer
volumes:
- ./docker-dev-volumes/app/data:/dv
- ./docker-dev-volumes/app/secrets:/secrets
- ./target/dataverse:/opt/payara/deployments/dataverse:ro
tmpfs:
- /dumps:mode=770,size=2052M,uid=1000,gid=1000
- /tmp:mode=770,size=2052M,uid=1000,gid=1000
mem_limit: 2684354560 # 2.5 GiB
mem_reservation: 1024m
privileged: false
dev_bootstrap:
container_name: "dev_bootstrap"
image: ${CONFIG_BAKER_IMAGE:-gdcc/configbaker:unstable}
restart: "no"
command:
- bootstrap.sh
- dev
networks:
- dataverse
volumes:
- ./docker-dev-volumes/solr/data:/var/solr
dev_dv_initializer:
container_name: "dev_dv_initializer"
image: ${CONFIG_BAKER_IMAGE:-gdcc/configbaker:unstable}
restart: "no"
command:
- sh
- -c
- "fix-fs-perms.sh dv"
volumes:
- ./docker-dev-volumes/app/data:/dv
dev_postgres:
container_name: "dev_postgres"
hostname: postgres
image: postgres:${POSTGRES_VERSION}
restart: on-failure
environment:
- POSTGRES_USER=${DATAVERSE_DB_USER}
- POSTGRES_PASSWORD=secret
ports:
- "5432:5432"
networks:
- dataverse
volumes:
- ./docker-dev-volumes/postgresql/data:/var/lib/postgresql/data
dev_solr_initializer:
container_name: "dev_solr_initializer"
image: ${CONFIG_BAKER_IMAGE:-gdcc/configbaker:unstable}
restart: "no"
command:
- sh
- -c
- "fix-fs-perms.sh solr && cp -a /template/* /solr-template"
volumes:
- ./docker-dev-volumes/solr/data:/var/solr
- ./docker-dev-volumes/solr/conf:/solr-template
# This is optional. Uncomment to try and experiment with schema sidecar.
# dev_solr_schema_sidecar:
# container_name: "dev_solr_schema_sidecar"
# hostname: "solr-schema"
# image: gdcc/configbaker:unstable
# depends_on:
# - dev_dv_initializer
# - dev_solr
# restart: on-failure
# networks:
# - dataverse
# # Note: no quotes here - they will become part of the arguments passed to the script!
# command: >-
# solr-driver.sh --mode watch --startup-check wait
# --dataverse-url http://dataverse:8080
# --solr-url http://solr:8983
# --core collection1
# volumes:
# - ./docker-dev-volumes/solr/data:/var/solr
dev_solr:
container_name: "dev_solr"
hostname: "solr"
image: solr:${SOLR_VERSION}
depends_on:
- dev_solr_initializer
restart: on-failure
ports:
- "8983:8983"
environment:
- SOLR_OPTS=-Dsolr.jetty.request.header.size=102400
networks:
- dataverse
command:
- "solr-precreate"
- "collection1"
- "/template"
volumes:
- ./docker-dev-volumes/solr/data:/var/solr
- ./docker-dev-volumes/solr/conf:/template
dev_smtp:
container_name: "dev_smtp"
hostname: "smtp"
image: maildev/maildev:2.0.5
restart: on-failure
ports:
- "25:25" # smtp server
- "1080:1080" # web ui
environment:
- MAILDEV_SMTP_PORT=25
- MAILDEV_MAIL_DIRECTORY=/mail
networks:
- dataverse
#volumes:
# - ./docker-dev-volumes/smtp/data:/mail
tmpfs:
- /mail:mode=770,size=128M,uid=1000,gid=1000
dev_keycloak:
container_name: "dev_keycloak"
image: 'quay.io/keycloak/keycloak:26.3.2'
hostname: keycloak
environment:
- KEYCLOAK_ADMIN=kcadmin
- KEYCLOAK_ADMIN_PASSWORD=kcpassword
- KEYCLOAK_LOGLEVEL=DEBUG
- KC_HOSTNAME_STRICT=false
networks:
dataverse:
aliases:
- keycloak.mydomain.com #create a DNS alias within the network (add the same alias to your /etc/hosts to get a working OIDC flow)
command: start-dev --import-realm --http-port=8090 # change port to 8090, so within the network and external the same port is used
ports:
- "8090:8090"
volumes:
- './conf/keycloak/test-realm.json:/opt/keycloak/data/import/test-realm.json'
# This proxy configuration is only intended to be used for development purposes!
# DO NOT USE IN PRODUCTION! HIGH SECURITY RISK!
dev_proxy:
image: caddy:2-alpine
# The command below is enough to enable using the admin gui, but it will not rewrite location headers to HTTP.
# To achieve rewriting from https:// to http://, we need a simple configuration file
#command: ["caddy", "reverse-proxy", "-f", ":4848", "-t", "https://dataverse:4848", "--insecure"]
command: ["caddy", "run", "-c", "/Caddyfile"]
ports:
- "4848:4848" # Will expose Payara Admin Console (HTTPS) as HTTP
restart: always
volumes:
- ./conf/proxy/Caddyfile:/Caddyfile:ro
depends_on:
- dev_dataverse
networks:
- dataverse
dev_localstack:
container_name: "dev_localstack"
hostname: "localstack"
image: localstack/localstack:4.2.0
restart: on-failure
ports:
- "127.0.0.1:4566:4566"
environment:
- DEBUG=${DEBUG-}
- DOCKER_HOST=unix:///var/run/docker.sock
- HOSTNAME_EXTERNAL=localstack
networks:
- dataverse
volumes:
- ./conf/localstack:/etc/localstack/init/ready.d
tmpfs:
- /localstack:mode=770,size=128M,uid=1000,gid=1000
dev_minio:
container_name: "dev_minio"
hostname: "minio"
image: minio/minio
restart: on-failure
ports:
- "9000:9000"
- "9001:9001"
networks:
- dataverse
volumes:
- ./docker-dev-volumes/minio_storage:/data
environment:
MINIO_ROOT_USER: 4cc355_k3y
MINIO_ROOT_PASSWORD: s3cr3t_4cc355_k3y
command: server /data
previewers-provider:
container_name: previewers-provider
hostname: previewers-provider
image: trivadis/dataverse-previewers-provider:latest
ports:
- "9080:9080"
networks:
- dataverse
environment:
# have nginx match the port we run previewers on
- NGINX_HTTP_PORT=9080
- PREVIEWERS_PROVIDER_URL=http://localhost:9080
- VERSIONS="v1.4,betatest"
# https://docs.docker.com/reference/compose-file/services/#platform
# https://github.com/fabric8io/docker-maven-plugin/issues/1750
platform: linux/amd64
register-previewers:
container_name: register-previewers
hostname: register-previewers
image: trivadis/dataverse-deploy-previewers:latest
networks:
- dataverse
environment:
- DATAVERSE_URL=http://dataverse:8080
- TIMEOUT=10m
- PREVIEWERS_PROVIDER_URL=http://localhost:9080
# Uncomment to specify which previewers you want. Otherwise you get all of them.
#- INCLUDE_PREVIEWERS=text,html,pdf,csv,comma-separated-values,tsv,tab-separated-values,jpeg,png,gif,markdown,x-markdown
- EXCLUDE_PREVIEWERS=
- REMOVE_EXISTING=true
command:
- deploy
restart: "no"
platform: linux/amd64
networks:
dataverse:
driver: bridge
# Use with a published gdcc/dataverse image (no local Maven build).
#
# docker compose -f docker-compose-dev.yml -f docker-compose-prebuilt.yml up
#
# Without this override, ./target/dataverse is mounted over the image deployment
# directory. When that folder is empty, Payara fails with:
# "Archive type of /opt/payara/deployments/dataverse was not recognized"
services:
dev_dataverse:
volumes: !reset
- ./docker-dev-volumes/app/data:/dv
- ./docker-dev-volumes/app/secrets:/secrets

Sync branding from live Dataverse to local Docker dev

sync-branding-from-live.sh copies installation branding from a production Dataverse site into a local docker-compose-dev instance.

  • The live server is only read from (never modified). Every run fetches from production unless you use --apply-only.
  • The Docker container does not contact production; your laptop fetches data and writes the host volume (docker-dev-volumes/app/data, mounted as /dv in the container).
  • Local install (copy files + PUT settings to http://localhost:8080) runs by default; use --no-apply to fetch and stage without touching local Dataverse yet.

Prerequisites

  • Local dev stack running (docker compose -f docker-compose-dev.yml up).
  • On your Mac: curl, jq, ssh, scp.
  • LIVE_URL reachable over HTTPS from your laptop.
  • SSH_HOST with access to run curl http://127.0.0.1:8080/api/admin/settings on the server (admin API is usually localhost-only).

Configuration via .env

The script loads branding variables from the repository root .env when they are not already set in your shell. Add lines such as:

# Branding sync (scripts/dev/sync-branding-from-live.sh)
LIVE_URL=https://your-production-dataverse.edu
SSH_HOST=you@prod-server
# LIVE_API_TOKEN=...
# LOCAL_API_TOKEN=...

Shell export values take precedence over .env. Only the keys listed under Environment variables are read from .env (other keys like APP_IMAGE are ignored).

Quick start

From the repository root (with LIVE_URL and SSH_HOST in .env or exported):

chmod +x scripts/dev/sync-branding-from-live.sh

# Recommended for JHU-style sites (docroot css/fonts, logo, extra settings):
./scripts/dev/sync-branding-from-live.sh --closer --yes

# Standard installation branding only:
./scripts/dev/sync-branding-from-live.sh --yes

# Fetch only, inspect staging, then install later
./scripts/dev/sync-branding-from-live.sh --no-apply
ls -la docker-dev-volumes/app/data/branding-sync/
./scripts/dev/sync-branding-from-live.sh --apply-only --yes

--closer vs --with-docroot: --closer implies --with-docroot and adds JHU-specific mapping (see below). Use --with-docroot alone if you only need css/main.css, fonts, and the navbar logo without URL rewrites, main.css merge, or extra settings from raw admin JSON.

Root collection theme (usually empty on JHU):

./scripts/dev/sync-branding-from-live.sh --with-theme --yes

Environment variables

Variable Default Description
LIVE_URL (required for pull) Public production URL
SSH_HOST (required for pull) user@host for admin settings
LOCAL_URL http://localhost:8080 Local Dataverse
DV_DATA_DIR ./docker-dev-volumes/app/data Host path mounted as /dv
SYNC_DIR $DV_DATA_DIR/branding-sync Staging directory (safe to delete)
REMOTE_DATAVERSE_URL http://127.0.0.1:8080 URL used in SSH curl on the server
ROOT_ALIAS root Root collection alias
REMOTE_BRANDING_DIR (auto on server) Override /var/www/dataverse/branding if non-standard
REMOTE_DOCROOT (auto: payara6/7) Payara domain1/docroot on the server
REMOTE_DOCROOT_PATHS css/main.css Comma-separated paths under docroot to scp
REMOTE_EXTRA_FILES JHU navbar logo default Comma-separated remote:host_under_DV_DATA_DIR pairs (e.g. Apache /var/www/html/images/...)
DOCKER_CONTAINER dev_dataverse Container name for verify / auto docker cp
LIVE_API_TOKEN (optional) Live API token for --with-theme
LOCAL_API_TOKEN (optional) Local token for root theme PUT and --closer root display name

Do not commit API tokens. Prefer .env for LIVE_URL / SSH_HOST and keep tokens out of git; add .env to .gitignore if your fork does not already ignore it.

Flags

Flag Description
--no-apply Fetch from live and stage only; skip local Docker install
--apply-only Install from existing branding-sync/; do not fetch from live again
--with-theme Pull root theme metadata and /logos/{id}/ images
--with-docroot Pull Payara docroot files (css/main.css, navbar images, etc.)
--closer Max fidelity for JHU-like sites (implies --with-docroot): see Closer mode
--skip-analytics Skip :WebAnalyticsCode
--dry-run Print actions without writing
--yes Skip confirmation before apply
-h, --help Help text

What gets synced

Database settings (whitelist only):

  • Custom homepage, header, footer, CSS, navbar logo paths
  • Navbar About / Guides / Support URLs
  • Footer copyright, installation name, languages, disable root theme flag
  • Web analytics file path (unless --skip-analytics)

Files:

  • Customization bodies via GET /api/info/settings/customization/{type} (HTTPS from laptop)
  • SSH scp fallback when a setting points at a readable absolute path on the server
  • Root theme images via GET /logos/{id}/{file} when using --with-theme

Not synced: auth, DOI, email, rate limits, datasets, or other admin settings.

Closer mode

--closer is for production sites that store extra look-and-feel outside the standard branding whitelist (e.g. JHU). It still only uses stock Dataverse 6.7.1 setting names on apply.

Step Behavior
Pull Same as default + --with-docroot (docroot CSS, entire fonts/ dir, Apache/static extras via REMOTE_EXTRA_FILES)
Raw admin JSON Reads live-admin-settings-raw.json and maps fork-only keys to stock keys: :instanceNameFull:InstallationName; :instanceBrandingHeader=true:DisableRootDataverseTheme=true; sets :LogoCustomizationFile to /logos/navbar/... when the logo file was pulled
Apply Rewrites production hostnames in branding/*.html to LOCAL_URL; appends docroot/css/main.css into custom-stylesheet.css (stock Docker does not serve /css/*); rewrites navbar /images/.../logos/navbar/...; optional PUT root collection display name from :instanceNameFull if LOCAL_API_TOKEN is set

Fork-only keys (:instanceLogoFile, :instanceBrandingHeader HTML templates, etc.) are not applied on stock 6.7.1.

Staging layout

docker-dev-volumes/app/data/branding-sync/
  live-branding-settings.json      # filtered admin settings (apply source)
  live-admin-settings-raw.json     # full admin API dump (--closer reads fork keys)
  manifest.json                    # mapping for apply (includes closer/withDocroot flags)
  files/                           # pulled customization bodies
  root-theme.json                  # optional (--with-theme)

After apply, files are copied to:

  • docker-dev-volumes/app/data/branding//dv/branding/ in the container
  • docker-dev-volumes/app/data/docroot//dv/docroot/ (and URLs under /logos/...)

If the running container does not see the host bind mount, the script auto-runs docker cp for branding/ and docroot/ when verification fails (override container with DOCKER_CONTAINER).

Verification

curl -s http://localhost:8080/api/admin/settings/:HeaderCustomizationFile
# Expect a path like /dv/branding/custom-header.html

open http://localhost:8080

Hard-refresh the browser. Compare with the live site.

Troubleshooting

SSH admin settings fail

  • On the server: curl -fsS http://127.0.0.1:8080/api/admin/settings | head
  • Confirm Payara listens on 8080 and admin API is allowed from localhost.
  • Try export REMOTE_DATAVERSE_URL=http://127.0.0.1:8080 (or the correct loopback URL).

Customization type returns 404

That branding piece is not configured on production. The script skips it.

Apply fails: local Dataverse not reachable

Start dev stack: docker compose -f docker-compose-dev.yml up -d (or your usual command).

Settings applied but the site still looks like stock Dataverse

The script writes files to docker-dev-volumes/app/data/branding/ on the host. Payara reads /dv/branding/... inside the container. Those only match if dev_dataverse uses the compose bind mount:

./docker-dev-volumes/app/data:/dv

If the container was started with a different compose project or an old named volume, settings in the database point at /dv/branding/... but the files are missing inside the container.

Check:

docker exec dev_dataverse test -r /dv/branding/custom-header.html && echo OK || echo MISSING

Fix (from repo root):

docker compose -f docker-compose-dev.yml down
docker compose -f docker-compose-dev.yml up -d

Quick fix without recreating (also attempted automatically at end of apply when branding is missing in the container):

docker cp docker-dev-volumes/app/data/branding dev_dataverse:/dv/branding
docker cp docker-dev-volumes/app/data/docroot/. dev_dataverse:/dv/docroot/

A container restart alone does not fix a wrong /dv volume. You do not need a restart after a successful bind mount + docker cp or re-up.

What lives where on JHU (example)

Server path Role Synced by
/var/www/dataverse/branding/*.html, custom-stylesheet.css Installation customization (DB settings point here) Default pull (SSH)
.../docroot/css/main.css, .../docroot/fonts/ Extra CSS and webfonts --with-docroot / --closer (REMOTE_DOCROOT_PATHS, fonts dir with --closer)
/var/www/html/images/*.png (Apache) Navbar logo in custom-header.html (/images/...) --with-docroot / --closer (REMOTE_EXTRA_FILES; header rewritten to /logos/navbar/...)
custom-header-old.html Old backup; not referenced by settings Skipped

Stock Dataverse 6.7.1 does not understand JHU-only database keys (:instanceLogoFile, :instanceBrandingHeader, etc.). --closer maps a subset to stock keys; the rest need a fork or manual UI work.

Navbar logo 404 on local (/images/...)

On production, Apache serves /var/www/html/images/. Stock Dataverse in Docker only maps /logos/* from docroot, not /images/*.

With --with-docroot or --closer, the script pulls the logo from REMOTE_EXTRA_FILES (default: Apache path under /var/www/html/images/...) and rewrites custom-header.html to use /logos/navbar/... locally.

If the logo is still missing, confirm:

curl -I http://localhost:8080/logos/navbar/libraries.logo.small.horizontal.white.cropped.png

Colors still wrong

Colors come mainly from custom-stylesheet.css (injected via :StyleCustomizationFile). On production, docroot/css/main.css may add rules; stock Docker does not serve /css/main.css. Use --closer (or merge manually) so main.css is appended into custom-stylesheet.css. Ensure /dv/branding/custom-stylesheet.css exists in the container and hard-refresh the browser.

Homepage looks wrong locally

Custom homepage HTML may hard-code production URLs or a non-root collection alias. --closer rewrites the live hostname to LOCAL_URL in branding/*.html; edit further under docker-dev-volumes/app/data/branding/ if needed.

Root display name still says "Root"

--closer sets the root collection name via API only when LOCAL_API_TOKEN is set and :instanceNameFull exists in live-admin-settings-raw.json. Otherwise set it in the UI or add the token to .env.

Root theme colors not updated

Logo files are copied under docroot/logos/{localRootId}/. Theme JSON is applied only if LOCAL_API_TOKEN is set. You can also set colors via Edit Dataverse → Theme in the UI.

Security

  • Production: GET only over SSH; read-only scp/test -r for file fallback.
  • No PUT/DELETE/POST to production URLs.
  • Secrets are not printed by the script.
#!/usr/bin/env bash
#
# sync-branding-from-live.sh
#
# Fetch installation branding from a live Dataverse site (read-only) and install
# it on local docker-compose-dev. The live server is never modified. Local install
# uses LOCAL_URL only (default http://localhost:8080).
#
# See scripts/dev/sync-branding-from-live.md for usage.
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
# Keys loaded from ${REPO_ROOT}/.env when not already set in the shell
BRANDING_ENV_KEYS=(
LIVE_URL
SSH_HOST
LOCAL_URL
DV_DATA_DIR
SYNC_DIR
ROOT_ALIAS
REMOTE_DATAVERSE_URL
REMOTE_BRANDING_DIR
REMOTE_DOCROOT
LIVE_API_TOKEN
LOCAL_API_TOKEN
DOCKER_CONTAINER
REMOTE_DOCROOT_PATHS
REMOTE_EXTRA_FILES
)
is_branding_env_key() {
local want="$1" k
for k in "${BRANDING_ENV_KEYS[@]}"; do
[[ "$k" == "$want" ]] && return 0
done
return 1
}
# Load branding-related variables from repo .env (does not override non-empty env)
load_repo_env() {
local env_file="${REPO_ROOT}/.env"
[[ -f "$env_file" ]] || return 0
local line key val existing
while IFS= read -r line || [[ -n "$line" ]]; do
line="$(printf '%s' "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
[[ -z "$line" ]] && continue
[[ "$line" == \#* ]] && continue
line="${line#export }"
[[ "$line" == *"="* ]] || continue
key="${line%%=*}"
val="${line#*=}"
is_branding_env_key "$key" || continue
existing="$(printenv "$key" 2>/dev/null || true)"
[[ -n "$existing" ]] && continue
case "$val" in
\"*\") val="${val#\"}"; val="${val%\"}" ;;
\'*\') val="${val#\'}"; val="${val%\'}" ;;
esac
export "${key}=${val}"
done < "$env_file"
}
load_repo_env
# --- defaults (after .env; empty values still get defaults) ---
LIVE_URL="${LIVE_URL:-}"
SSH_HOST="${SSH_HOST:-}"
LOCAL_URL="${LOCAL_URL:-http://localhost:8080}"
DV_DATA_DIR="${DV_DATA_DIR:-${REPO_ROOT}/docker-dev-volumes/app/data}"
SYNC_DIR="${SYNC_DIR:-${DV_DATA_DIR}/branding-sync}"
ROOT_ALIAS="${ROOT_ALIAS:-root}"
REMOTE_DATAVERSE_URL="${REMOTE_DATAVERSE_URL:-http://127.0.0.1:8080}"
REMOTE_BRANDING_DIR="${REMOTE_BRANDING_DIR:-}"
REMOTE_DOCROOT="${REMOTE_DOCROOT:-}"
REMOTE_DOCROOT_PATHS="${REMOTE_DOCROOT_PATHS:-css/main.css}"
# remote_path:host_path_under_DV_DATA_DIR (JHU navbar logo is often under Apache, not Payara docroot)
REMOTE_EXTRA_FILES="${REMOTE_EXTRA_FILES:-/var/www/html/images/libraries.logo.small.horizontal.white.cropped.png:docroot/images/libraries.logo.small.horizontal.white.cropped.png}"
DOCKER_CONTAINER="${DOCKER_CONTAINER:-dev_dataverse}"
LIVE_API_TOKEN="${LIVE_API_TOKEN:-}"
LOCAL_API_TOKEN="${LOCAL_API_TOKEN:-}"
SKIP_LOCAL_APPLY=false
APPLY_ONLY=false
WITH_THEME=false
WITH_DOCROOT=false
CLOSER_MODE=false
SKIP_ANALYTICS=false
DRY_RUN=false
ASSUME_YES=false
# Whitelisted branding settings (never bulk-import all admin settings)
BRANDING_KEYS=(
':HomePageCustomizationFile'
':HeaderCustomizationFile'
':FooterCustomizationFile'
':StyleCustomizationFile'
':LogoCustomizationFile'
':WebAnalyticsCode'
':NavbarAboutUrl'
':NavbarGuidesUrl'
':NavbarSupportUrl'
':FooterCopyright'
':InstallationName'
':DisableRootDataverseTheme'
':Languages'
)
FILE_SETTING_KEYS=(
':HomePageCustomizationFile'
':HeaderCustomizationFile'
':FooterCustomizationFile'
':StyleCustomizationFile'
':LogoCustomizationFile'
':WebAnalyticsCode'
)
# customization API type for a setting key
custom_type_for_key() {
case "$1" in
':HomePageCustomizationFile') echo homePage ;;
':HeaderCustomizationFile') echo header ;;
':FooterCustomizationFile') echo footer ;;
':StyleCustomizationFile') echo style ;;
':LogoCustomizationFile') echo logo ;;
':WebAnalyticsCode') echo analytics ;;
*) echo "" ;;
esac
}
MANIFEST_JSON="${SYNC_DIR}/manifest.json"
SETTINGS_JSON="${SYNC_DIR}/live-branding-settings.json"
FILES_DIR="${SYNC_DIR}/files"
usage() {
cat <<'EOF'
Usage: sync-branding-from-live.sh [OPTIONS]
Fetch branding from a live Dataverse site (HTTPS + SSH, read-only) and
optionally install it on local docker-compose-dev. The live server is never
modified.
Required (unless --apply-only):
LIVE_URL Public live site URL (e.g. https://dataverse.example.edu)
SSH_HOST SSH target for admin settings (e.g. user@prod-server)
Options:
--no-apply Fetch from live and stage only; do not update local Docker
--apply-only Install from existing branding-sync/; do not fetch from live
--with-theme Also pull root collection theme images and metadata
--with-docroot Pull Payara docroot + Apache static files (css, fonts, navbar logo)
--closer Max fidelity for JHU-like sites (implies --with-docroot): extra
settings, URL rewrites, main.css merge, root name, logo path
--skip-analytics Skip :WebAnalyticsCode pull/apply
--dry-run Print actions without writing or applying
--yes Skip confirmation before apply
-h, --help Show this help
Environment (also read from repo .env if unset in shell):
LIVE_URL, SSH_HOST, LOCAL_URL, DV_DATA_DIR, SYNC_DIR, ROOT_ALIAS,
REMOTE_DATAVERSE_URL, REMOTE_BRANDING_DIR, REMOTE_DOCROOT, REMOTE_DOCROOT_PATHS,
REMOTE_EXTRA_FILES, LIVE_API_TOKEN, LOCAL_API_TOKEN, DOCKER_CONTAINER
Shell exports override .env. See scripts/dev/sync-branding-from-live.md
EOF
}
log() { echo "==> $*"; }
warn() { echo "WARNING: $*" >&2; }
die() { echo "ERROR: $*" >&2; exit 1; }
require_deps() {
local dep
for dep in curl jq ssh scp; do
command -v "$dep" >/dev/null 2>&1 || die "Missing required command: $dep"
done
}
is_file_setting() {
local key="$1"
local k
for k in "${FILE_SETTING_KEYS[@]}"; do
[[ "$k" == "$key" ]] && return 0
done
return 1
}
# Map production path to container path used in local settings
map_to_local_container_path() {
local key="$1" value="$2"
local basename
basename="$(basename "$value")"
case "$key" in
':LogoCustomizationFile')
if [[ "$value" == /logos/* ]]; then
echo "$value"
else
echo "/logos/navbar/${basename}"
fi
;;
':HomePageCustomizationFile'|':HeaderCustomizationFile'|':FooterCustomizationFile'|':StyleCustomizationFile'|':WebAnalyticsCode')
echo "/dv/branding/${basename}"
;;
*)
echo "$value"
;;
esac
}
# Host filesystem path under DV_DATA_DIR
host_path_for_container_path() {
local container_path="$1"
if [[ "$container_path" == /dv/* ]]; then
echo "${DV_DATA_DIR}${container_path#/dv}"
elif [[ "$container_path" == /logos/* ]]; then
echo "${DV_DATA_DIR}/docroot${container_path}"
else
echo "${DV_DATA_DIR}/${container_path#/}"
fi
}
run() {
if [[ "$DRY_RUN" == true ]]; then
echo "[dry-run] $*"
else
"$@"
fi
}
curl_live_get() {
local url="$1"
local extra=()
[[ -n "$LIVE_API_TOKEN" ]] && extra+=(-H "X-Dataverse-key: ${LIVE_API_TOKEN}")
curl -fsS "${extra[@]}" "$url"
}
curl_local_put() {
local url="$1"
local data="$2"
local extra=()
[[ -n "$LOCAL_API_TOKEN" ]] && extra+=(-H "X-Dataverse-key: ${LOCAL_API_TOKEN}")
if [[ "$DRY_RUN" == true ]]; then
echo "[dry-run] curl -X PUT ${url}"
echo "[dry-run] body: ${data:0:120}$([[ ${#data} -gt 120 ]] && echo ...)"
return 0
fi
curl -fsS "${extra[@]}" -X PUT "$url" -d "$data" >/dev/null
}
# --- pull: SSH admin settings (GET only on server) ---
pull_remote_settings() {
log "Fetching branding settings via SSH (${SSH_HOST}) -> ${REMOTE_DATAVERSE_URL}/api/admin/settings"
local remote_filter
remote_filter="$(printf '%s\n' "${BRANDING_KEYS[@]}" | jq -R . | jq -s .)"
local tmp
tmp="$(mktemp)"
if [[ "$DRY_RUN" == true ]]; then
echo "[dry-run] ssh ${SSH_HOST} curl -fsS ${REMOTE_DATAVERSE_URL}/api/admin/settings"
echo '{}' > "$tmp"
else
if ! ssh -o BatchMode=yes "${SSH_HOST}" bash -s -- "${REMOTE_DATAVERSE_URL}" <<'REMOTE' >"$tmp"
set -euo pipefail
REMOTE_URL="$1"
# Safety: remote helper only performs GET
curl -fsS "${REMOTE_URL}/api/admin/settings"
REMOTE
then
die "SSH admin settings pull failed. Check SSH_HOST and that admin API allows localhost on the server."
fi
fi
if [[ "$DRY_RUN" != true ]]; then
jq --argjson keys "$remote_filter" '
reduce $keys[] as $k ({}; if .data[$k] != null then . + { ($k): .data[$k] } else . end)
' "$tmp" > "${SETTINGS_JSON}.raw" 2>/dev/null || true
if jq -e '.data' "$tmp" >/dev/null 2>&1; then
jq --argjson keys "$remote_filter" '
.data as $d |
reduce $keys[] as $k ({}; if $d[$k] != null then . + { ($k): $d[$k] } else . end)
' "$tmp" > "$SETTINGS_JSON"
elif jq -e 'type == "object"' "$tmp" >/dev/null 2>&1; then
jq --argjson keys "$remote_filter" '
. as $d |
reduce $keys[] as $k ({}; if $d[$k] != null then . + { ($k): $d[$k] } else . end)
' "$tmp" > "$SETTINGS_JSON"
else
die "Unexpected response from remote admin settings API"
fi
local n
n="$(jq 'length' "$SETTINGS_JSON")"
run cp "$tmp" "${SYNC_DIR}/live-admin-settings-raw.json"
log "Wrote ${SETTINGS_JSON} (${n} branding setting(s))"
if [[ "$n" -eq 0 ]]; then
warn "Admin API returned no whitelisted branding keys (see live-admin-settings-raw.json)."
warn "Try --with-theme if look-and-feel comes from the root collection theme."
fi
rm -f "$tmp" "${SETTINGS_JSON}.raw"
fi
}
# Add stock settings derived from JHU-only keys in live-admin-settings-raw.json
merge_closer_settings_from_raw() {
local raw="${SYNC_DIR}/live-admin-settings-raw.json"
[[ -f "$raw" ]] || return 0
log "Merging closer settings from raw admin API (stock keys only)"
local inst_name disable_root logo_path
inst_name="$(jq -r '.data[":instanceNameFull"] // empty' "$raw")"
disable_root="$(jq -r '.data[":instanceBrandingHeader"] // empty' "$raw")"
logo_path="/logos/navbar/libraries.logo.small.horizontal.white.cropped.png"
local merged
merged="$(jq '.' "$SETTINGS_JSON")"
if [[ -n "$inst_name" && "$inst_name" != "null" ]]; then
merged="$(echo "$merged" | jq --arg v "$inst_name" '. + {":InstallationName": $v}')"
fi
if [[ "$disable_root" == "true" ]]; then
merged="$(echo "$merged" | jq '. + {":DisableRootDataverseTheme": "true"}')"
fi
if [[ -f "${DV_DATA_DIR}/docroot/logos/navbar/libraries.logo.small.horizontal.white.cropped.png" ]] \
|| [[ -f "${DV_DATA_DIR}/docroot/images/libraries.logo.small.horizontal.white.cropped.png" ]]; then
merged="$(echo "$merged" | jq --arg v "$logo_path" '. + {":LogoCustomizationFile": $v}')"
fi
echo "$merged" > "$SETTINGS_JSON"
}
extension_for_content_type() {
local ct="$1"
case "$ct" in
*html*) echo ".html" ;;
*css*) echo ".css" ;;
*png*) echo ".png" ;;
*jpeg*|*jpg*) echo ".jpg" ;;
*svg*) echo ".svg" ;;
*javascript*) echo ".js" ;;
*) echo "" ;;
esac
}
# --- pull: HTTPS customization API from laptop ---
pull_customization_https() {
local ctype="$1"
local out_base="$2"
local tmp_headers tmp_body http_code content_type ext
tmp_headers="$(mktemp)"
tmp_body="$(mktemp)"
if [[ "$DRY_RUN" == true ]]; then
echo "[dry-run] curl ${LIVE_URL}/api/info/settings/customization/${ctype}"
rm -f "$tmp_headers" "$tmp_body"
return 1
fi
http_code="$(curl -sS -D "$tmp_headers" -o "$tmp_body" -w '%{http_code}' \
"${LIVE_URL}/api/info/settings/customization/${ctype}" || echo "000")"
if [[ "$http_code" != "200" ]]; then
rm -f "$tmp_headers" "$tmp_body"
log " (skip) customization API '${ctype}' HTTP ${http_code} (will use SSH file copy if configured)"
return 1
fi
content_type="$(grep -i '^content-type:' "$tmp_headers" | head -1 | cut -d: -f2- | tr -d '[:space:]' | cut -d';' -f1)"
ext="$(extension_for_content_type "$content_type")"
[[ -z "$ext" ]] && ext=".bin"
local out_file="${out_base}${ext}"
run mkdir -p "$(dirname "$out_file")"
run cp "$tmp_body" "$out_file"
rm -f "$tmp_headers" "$tmp_body"
log " saved customization ${ctype} -> ${out_file}"
echo "$out_file"
}
# Resolve Payara docroot on the server (JHU may use payara6, payara7, or payara)
resolve_remote_docroot() {
if [[ -n "$REMOTE_DOCROOT" ]]; then
echo "$REMOTE_DOCROOT"
return 0
fi
local candidate
for candidate in \
/usr/local/payara7/glassfish/domains/domain1/docroot \
/usr/local/payara6/glassfish/domains/domain1/docroot \
/usr/local/payara/glassfish/domains/domain1/docroot; do
if [[ "$DRY_RUN" == true ]]; then
echo "$candidate"
return 0
fi
if ssh -o BatchMode=yes "${SSH_HOST}" "test -d $(printf '%q' "$candidate")" 2>/dev/null; then
echo "$candidate"
return 0
fi
done
return 1
}
# Extra static files outside Payara docroot (e.g. Apache /var/www/html/images/...)
pull_extra_static_files() {
local pair remote local_path
[[ -z "$REMOTE_EXTRA_FILES" ]] && return 0
log "Fetching extra static files (SSH)"
IFS=',' read -ra pairs <<< "$REMOTE_EXTRA_FILES"
for pair in "${pairs[@]}"; do
pair="$(printf '%s' "$pair" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
[[ -z "$pair" || "$pair" != *:* ]] && continue
remote="${pair%%:*}"
local_path="${DV_DATA_DIR}/${pair#*:}"
pull_ssh_file "$remote" "$local_path" || true
done
}
# Payara docroot static files (e.g. /css/main.css) — not under /var/www/dataverse/branding
pull_docroot_assets() {
local docroot rel local_path
docroot="$(resolve_remote_docroot)" || {
warn "Could not find Payara docroot on server; set REMOTE_DOCROOT in .env"
return 1
}
log "Fetching docroot assets from ${docroot} (SSH)"
IFS=',' read -ra rel_paths <<< "$REMOTE_DOCROOT_PATHS"
for rel in "${rel_paths[@]}"; do
rel="$(printf '%s' "$rel" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
[[ -z "$rel" ]] && continue
local_path="${DV_DATA_DIR}/docroot/${rel}"
pull_ssh_file "${docroot}/${rel}" "$local_path" || true
done
if [[ "$CLOSER_MODE" == true ]]; then
pull_docroot_fonts "$docroot"
fi
}
pull_docroot_fonts() {
local docroot="$1"
local remote_fonts="${docroot}/fonts"
local local_fonts="${DV_DATA_DIR}/docroot/fonts"
if [[ "$DRY_RUN" == true ]]; then
echo "[dry-run] scp -r ${SSH_HOST}:${remote_fonts}/ -> ${local_fonts}/"
return 0
fi
if ssh -o BatchMode=yes "${SSH_HOST}" "test -d $(printf '%q' "$remote_fonts")" 2>/dev/null; then
run mkdir -p "$local_fonts"
scp -qr "${SSH_HOST}:${remote_fonts}/." "$local_fonts/"
log " ${remote_fonts}/ -> ${local_fonts}/"
else
warn " no fonts directory at ${remote_fonts}"
fi
}
# Copy branding files from absolute paths in live settings (SSH read-only)
pull_branding_files_via_ssh() {
local key val basename dest
for key in "${FILE_SETTING_KEYS[@]}"; do
if [[ "$SKIP_ANALYTICS" == true && "$key" == ':WebAnalyticsCode' ]]; then
continue
fi
val="$(jq -r --arg k "$key" '.[$k] // empty' "$SETTINGS_JSON")"
[[ -z "$val" || "$val" == "null" || "$val" != /* ]] && continue
basename="$(basename "$val")"
dest="${FILES_DIR}/${basename}"
pull_ssh_file "$val" "$dest" || true
done
}
# --- pull: SSH read-only file fallback ---
pull_ssh_file() {
local remote_path="$1"
local local_path="$2"
if [[ "$DRY_RUN" == true ]]; then
echo "[dry-run] scp ${SSH_HOST}:${remote_path} -> ${local_path}"
return 0
fi
if ssh -o BatchMode=yes "${SSH_HOST}" "test -r $(printf '%q' "$remote_path")"; then
run mkdir -p "$(dirname "$local_path")"
scp -q "${SSH_HOST}:${remote_path}" "$local_path"
log " ${remote_path} -> ${local_path}"
return 0
fi
warn " ssh fallback: not readable on server: ${remote_path}"
return 1
}
# --- pull: root theme (--with-theme) ---
pull_root_theme() {
log "Fetching root collection theme (${ROOT_ALIAS})"
local theme_json="${SYNC_DIR}/root-theme.json"
local live_dv_json="${SYNC_DIR}/live-root-dataverse.json"
if [[ "$DRY_RUN" == true ]]; then
echo "[dry-run] GET ${LIVE_URL}/api/dataverses/${ROOT_ALIAS}"
return 0
fi
curl_live_get "${LIVE_URL}/api/dataverses/${ROOT_ALIAS}" > "$live_dv_json"
jq '.data.theme // empty' "$live_dv_json" > "$theme_json"
if [[ ! -s "$theme_json" ]]; then
log " no theme configured on live root"
return 0
fi
log " wrote ${theme_json}"
local live_id logo logo_footer logo_thumb
live_id="$(jq -r '.data.id' "$live_dv_json")"
logo="$(jq -r '.logo // empty' "$theme_json")"
logo_footer="$(jq -r '.logoFooter // empty' "$theme_json")"
logo_thumb="$(jq -r '.logoThumbnail // empty' "$theme_json")"
local local_id="1"
if curl -fsS "${LOCAL_URL}/api/dataverses/${ROOT_ALIAS}" >/dev/null 2>&1; then
local_id="$(curl -fsS "${LOCAL_URL}/api/dataverses/${ROOT_ALIAS}" | jq -r '.data.id // 1')"
fi
log " live root id=${live_id}, local root id=${local_id}"
download_theme_logo() {
local file="$1"
[[ -z "$file" || "$file" == "null" ]] && return 0
local dest="${DV_DATA_DIR}/docroot/logos/${local_id}/${file}"
run mkdir -p "$(dirname "$dest")"
log " downloading ${LIVE_URL}/logos/${live_id}/${file}"
run curl -fsS "${LIVE_URL}/logos/${live_id}/${file}" -o "$dest"
echo "$dest"
}
download_theme_logo "$logo" >/dev/null || true
download_theme_logo "$logo_footer" >/dev/null || true
download_theme_logo "$logo_thumb" >/dev/null || true
jq -n \
--argjson theme "$(cat "$theme_json")" \
--arg localId "$local_id" \
--arg liveId "$live_id" \
'{ theme: $theme, localRootId: $localId, liveRootId: $liveId }' \
> "${SYNC_DIR}/theme-meta.json"
}
# Build manifest from settings + pulled files
build_manifest() {
log "Building manifest"
if [[ ! -f "$SETTINGS_JSON" ]]; then
die "Missing ${SETTINGS_JSON}; run pull first"
fi
local settings_obj="{}"
local key val local_path host_path staged rel_staged
local -a entries=()
for key in "${BRANDING_KEYS[@]}"; do
if [[ "$SKIP_ANALYTICS" == true && "$key" == ':WebAnalyticsCode' ]]; then
continue
fi
val="$(jq -r --arg k "$key" '.[$k] // empty' "$SETTINGS_JSON")"
[[ -z "$val" || "$val" == "null" ]] && continue
local_path="$(map_to_local_container_path "$key" "$val")"
host_path="$(host_path_for_container_path "$local_path")"
staged=""
rel_staged=""
if is_file_setting "$key"; then
local ctype
ctype="$(custom_type_for_key "$key")"
if [[ -n "$ctype" ]]; then
local staged_glob="${FILES_DIR}/${ctype}.*"
# shellcheck disable=SC2086
for f in $staged_glob; do
[[ -f "$f" ]] && staged="$f" && break
done
fi
if [[ -z "$staged" && "$val" == /* ]]; then
local fallback="${FILES_DIR}/$(basename "$val")"
if [[ ! -f "$fallback" ]]; then
pull_ssh_file "$val" "$fallback" || true
fi
[[ -f "$fallback" ]] && staged="$fallback"
fi
if [[ -n "$staged" ]]; then
rel_staged="${staged#${SYNC_DIR}/}"
fi
fi
entries+=("$(jq -n \
--arg key "$key" \
--arg liveValue "$val" \
--arg localPath "$local_path" \
--arg hostPath "$host_path" \
--arg staged "$rel_staged" \
--argjson isFile "$(is_file_setting "$key" && echo true || echo false)" \
'{
key: $key,
liveValue: $liveValue,
localContainerPath: $localPath,
hostPath: $hostPath,
stagedFile: (if $staged == "" then null else $staged end),
isFile: $isFile
}')")
done
local entries_json="[]"
if [[ ${#entries[@]} -gt 0 ]]; then
entries_json="$(printf '%s\n' "${entries[@]}" | jq -s '.')"
else
warn "No branding database settings found on live (empty ${SETTINGS_JSON})."
if [[ "$WITH_THEME" != true ]]; then
warn "If look-and-feel is from the root collection banner, re-run with --with-theme."
fi
fi
jq -n \
--arg liveUrl "$LIVE_URL" \
--arg sshHost "$SSH_HOST" \
--arg syncDir "$SYNC_DIR" \
--arg dvDataDir "$DV_DATA_DIR" \
--argjson withTheme "$( [[ "$WITH_THEME" == true ]] && echo true || echo false )" \
--argjson withDocroot "$( [[ "$WITH_DOCROOT" == true || "$CLOSER_MODE" == true ]] && echo true || echo false )" \
--argjson closer "$( [[ "$CLOSER_MODE" == true ]] && echo true || echo false )" \
--argjson settings "$entries_json" \
'{ liveUrl: $liveUrl, sshHost: $sshHost, syncDir: $syncDir, dvDataDir: $dvDataDir, withTheme: $withTheme, withDocroot: $withDocroot, closer: $closer, entries: $settings }' \
> "$MANIFEST_JSON"
log "Wrote ${MANIFEST_JSON}"
}
pull_phase() {
[[ -n "$LIVE_URL" ]] || die "LIVE_URL is required for pull"
[[ -n "$SSH_HOST" ]] || die "SSH_HOST is required for pull"
LIVE_URL="${LIVE_URL%/}"
LOCAL_URL="${LOCAL_URL%/}"
log "Fetch from live (read-only): ${LIVE_URL} via ${SSH_HOST} -> ${SYNC_DIR}"
run mkdir -p "$FILES_DIR" "${DV_DATA_DIR}/branding" "${DV_DATA_DIR}/docroot/logos/navbar"
pull_remote_settings
if [[ -f "$SETTINGS_JSON" ]] && [[ "$(jq 'length' "$SETTINGS_JSON")" -gt 0 ]]; then
log "Fetching branding files from server paths (SSH)"
pull_branding_files_via_ssh
fi
if [[ "$DRY_RUN" != true ]]; then
log "Trying customization API over HTTPS (optional)"
local key ctype
for key in "${FILE_SETTING_KEYS[@]}"; do
if [[ "$SKIP_ANALYTICS" == true && "$key" == ':WebAnalyticsCode' ]]; then
continue
fi
ctype="$(custom_type_for_key "$key")"
[[ -n "$ctype" ]] || continue
pull_customization_https "$ctype" "${FILES_DIR}/${ctype}" || true
done
else
for key in "${FILE_SETTING_KEYS[@]}"; do
ctype="$(custom_type_for_key "$key")"
[[ -n "$ctype" ]] || continue
pull_customization_https "$ctype" "${FILES_DIR}/${ctype}" || true
done
fi
if [[ "$WITH_THEME" == true ]]; then
pull_root_theme
fi
if [[ "$WITH_DOCROOT" == true || "$CLOSER_MODE" == true ]]; then
pull_docroot_assets
pull_extra_static_files
fi
if [[ "$CLOSER_MODE" == true ]]; then
merge_closer_settings_from_raw
fi
if [[ "$DRY_RUN" != true ]]; then
build_manifest
else
log "[dry-run] would build ${MANIFEST_JSON}"
fi
}
# Rewrite production hostnames in pulled HTML to LOCAL_URL
adapt_production_urls_in_branding() {
local live_host local_host f
live_host="$(printf '%s' "$LIVE_URL" | sed -E 's#^https?://##;s#/.*##')"
local_host="$(printf '%s' "$LOCAL_URL" | sed -E 's#^https?://##;s#/.*##')"
[[ -z "$live_host" || "$live_host" == "$local_host" ]] && return 0
log "Rewriting ${live_host} -> ${local_host} in branding HTML"
for f in "${DV_DATA_DIR}/branding/"*.html; do
[[ -f "$f" ]] || continue
if [[ "$DRY_RUN" == true ]]; then
echo "[dry-run] sed ${live_host} -> ${local_host} in ${f}"
else
sed "s|${live_host}|${local_host}|g" "$f" > "${f}.tmp" && mv "${f}.tmp" "$f"
fi
done
}
# main.css is not served at /css/* in stock Docker; merge into custom-stylesheet.css
merge_main_css_into_stylesheet() {
local main_css="${DV_DATA_DIR}/docroot/css/main.css"
local stylesheet="${DV_DATA_DIR}/branding/custom-stylesheet.css"
local marker="/* --- merged from docroot/css/main.css (sync-branding --closer) --- */"
[[ -f "$main_css" ]] || return 0
[[ -f "$stylesheet" ]] || return 0
if grep -qF "$marker" "$stylesheet" 2>/dev/null; then
log "main.css already merged into custom-stylesheet.css"
return 0
fi
log "Merging ${main_css} into custom-stylesheet.css"
if [[ "$DRY_RUN" == true ]]; then
echo "[dry-run] append main.css to custom-stylesheet.css"
return 0
fi
{
echo ""
echo "$marker"
cat "$main_css"
} >> "$stylesheet"
}
apply_closer_local_adaptations() {
adapt_production_urls_in_branding
merge_main_css_into_stylesheet
adapt_local_header_logo
}
apply_closer_root_name() {
local raw="${SYNC_DIR}/live-admin-settings-raw.json"
local name
[[ -f "$raw" ]] || return 0
[[ -n "$LOCAL_API_TOKEN" ]] || return 0
name="$(jq -r '.data[":instanceNameFull"] // empty' "$raw")"
[[ -z "$name" || "$name" == "null" ]] && return 0
log "Setting root dataverse display name to: ${name}"
if [[ "$DRY_RUN" == true ]]; then
echo "[dry-run] PUT ${LOCAL_URL}/api/dataverses/${ROOT_ALIAS} name=${name}"
return 0
fi
local body
body="$(jq -n --arg name "$name" '{name: $name}')"
curl -fsS -H "X-Dataverse-key: ${LOCAL_API_TOKEN}" \
-H "Content-Type: application/json" \
-X PUT "${LOCAL_URL}/api/dataverses/${ROOT_ALIAS}" \
--data-binary "$body" >/dev/null || warn "Root name PUT failed (may need fuller JSON body)"
}
# Local Docker has no Apache /images alias; Payara only serves /logos/* from docroot (see glassfish-web.xml)
adapt_local_header_logo() {
local header="${DV_DATA_DIR}/branding/custom-header.html"
local logo_src="${DV_DATA_DIR}/docroot/images/libraries.logo.small.horizontal.white.cropped.png"
local logo_dest_dir="${DV_DATA_DIR}/docroot/logos/navbar"
local logo_dest="${logo_dest_dir}/libraries.logo.small.horizontal.white.cropped.png"
local old_path="/images/libraries.logo.small.horizontal.white.cropped.png"
local new_path="/logos/navbar/libraries.logo.small.horizontal.white.cropped.png"
[[ -f "$header" ]] || return 0
[[ -f "$logo_src" ]] || return 0
run mkdir -p "$logo_dest_dir"
if [[ ! -f "$logo_dest" ]]; then
run cp "$logo_src" "$logo_dest"
fi
if grep -q "$old_path" "$header" 2>/dev/null; then
log "Pointing custom-header logo to ${new_path} (local /logos docroot)"
if [[ "$DRY_RUN" == true ]]; then
echo "[dry-run] sed ${old_path} -> ${new_path} in ${header}"
else
sed "s|${old_path}|${new_path}|g" "$header" > "${header}.tmp"
mv "${header}.tmp" "$header"
fi
fi
}
# --- apply phase ---
apply_files_from_manifest() {
local entry host_path staged sync_path
while IFS= read -r entry; do
local isFile staged
isFile="$(echo "$entry" | jq -r '.isFile')"
[[ "$isFile" != "true" ]] && continue
staged="$(echo "$entry" | jq -r '.stagedFile // empty')"
host_path="$(echo "$entry" | jq -r '.hostPath')"
[[ -z "$staged" || "$staged" == "null" ]] && continue
sync_path="${SYNC_DIR}/${staged}"
if [[ ! -f "$sync_path" ]]; then
warn "Staged file missing, skipping copy: ${sync_path}"
continue
fi
log " copy ${sync_path} -> ${host_path}"
run mkdir -p "$(dirname "$host_path")"
run cp "$sync_path" "$host_path"
done < <(jq -c '.entries[]' "$MANIFEST_JSON")
}
apply_settings_from_manifest() {
local count apply_value key local_path isFile live_val host_path
count="$(jq '.entries | length' "$MANIFEST_JSON")"
if [[ "$count" -eq 0 ]]; then
if [[ "$(jq -r '.withTheme' "$MANIFEST_JSON")" == "true" ]]; then
log "No installation branding settings to apply (theme-only or custom keys on live)."
else
warn "No branding settings to apply. Check live-admin-settings-raw.json or use --with-theme."
fi
return 0
fi
if [[ "$ASSUME_YES" != true && "$DRY_RUN" != true ]]; then
echo ""
read -r -p "Apply ${count} branding settings to ${LOCAL_URL}? [y/N] " reply
case "$reply" in
y|Y|yes|YES) ;;
*) die "Aborted by user" ;;
esac
fi
log "Applying settings to ${LOCAL_URL}"
while IFS= read -r entry; do
key="$(echo "$entry" | jq -r '.key')"
isFile="$(echo "$entry" | jq -r '.isFile')"
local_path="$(echo "$entry" | jq -r '.localContainerPath')"
live_val="$(echo "$entry" | jq -r '.liveValue')"
host_path="$(echo "$entry" | jq -r '.hostPath')"
if [[ "$isFile" == "true" ]]; then
if [[ ! -f "$host_path" ]]; then
warn "Skipping ${key}: file not found at ${host_path}"
continue
fi
apply_value="$local_path"
else
apply_value="$live_val"
fi
log " PUT ${key}"
curl_local_put "${LOCAL_URL}/api/admin/settings/${key}" "$apply_value" || \
warn "Failed to set ${key}"
done < <(jq -c '.entries[]' "$MANIFEST_JSON")
}
apply_root_theme() {
local theme_json="${SYNC_DIR}/root-theme.json"
[[ -f "$theme_json" && -s "$theme_json" ]] || return 0
if [[ -z "$LOCAL_API_TOKEN" ]]; then
warn "LOCAL_API_TOKEN not set; skipping root theme PUT (logo files may still be on disk)"
return 0
fi
log "Applying root collection theme via API"
local body
body="$(jq -n --slurpfile t "$theme_json" '{ theme: $t[0] }')"
if [[ "$DRY_RUN" == true ]]; then
echo "[dry-run] PUT ${LOCAL_URL}/api/dataverses/${ROOT_ALIAS}"
return 0
fi
if curl -fsS -H "X-Dataverse-key: ${LOCAL_API_TOKEN}" \
-H "Content-Type: application/json" \
-X PUT "${LOCAL_URL}/api/dataverses/${ROOT_ALIAS}" \
--data-binary "$body" >/dev/null; then
log " root theme updated"
else
warn "Root theme PUT failed"
fi
}
apply_phase() {
LOCAL_URL="${LOCAL_URL%/}"
if [[ ! -f "$MANIFEST_JSON" ]]; then
if [[ "$DRY_RUN" == true ]]; then
warn "No manifest at ${MANIFEST_JSON}; skipping apply (pull was likely --dry-run)"
return 0
fi
die "Missing ${MANIFEST_JSON}; run pull first (or check SYNC_DIR)"
fi
log "Preflight: ${LOCAL_URL}/api/info/version"
if [[ "$DRY_RUN" != true ]]; then
curl -fsS "${LOCAL_URL}/api/info/version" >/dev/null || \
die "Local Dataverse not reachable at ${LOCAL_URL}. Start docker compose dev stack."
else
echo "[dry-run] curl ${LOCAL_URL}/api/info/version"
fi
apply_files_from_manifest
if [[ "$(jq -r '.closer // false' "$MANIFEST_JSON")" == "true" ]]; then
apply_closer_local_adaptations
else
adapt_local_header_logo
fi
apply_settings_from_manifest
if [[ "$(jq -r '.withTheme' "$MANIFEST_JSON")" == "true" ]]; then
apply_root_theme
fi
if [[ "$(jq -r '.closer // false' "$MANIFEST_JSON")" == "true" ]]; then
apply_closer_root_name
fi
verify_local_container_files
}
# Ensure Payara can read files at /dv/branding inside the running container
verify_local_container_files() {
[[ "$DRY_RUN" == true ]] && return 0
local container="${DOCKER_CONTAINER:-dev_dataverse}"
local probe="/dv/branding/custom-header.html"
if ! command -v docker >/dev/null 2>&1; then
return 0
fi
if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$container"; then
warn "Container '${container}' not running; could not verify ${probe} inside Docker."
return 0
fi
local missing=false css_probe="/dv/docroot/css/main.css"
if docker exec "$container" test -r "$probe" 2>/dev/null; then
log "Verified: ${probe} is readable in ${container}"
else
warn "Missing in container: ${probe}"
missing=true
fi
if [[ "$(jq -r '.withDocroot // false' "$MANIFEST_JSON" 2>/dev/null)" == "true" ]] \
|| [[ "$WITH_DOCROOT" == true || "$CLOSER_MODE" == true ]]; then
if docker exec "$container" test -r "$css_probe" 2>/dev/null; then
log "Verified: ${css_probe} is readable in ${container}"
else
warn "Missing in container: ${css_probe} (main.css may be merged into custom-stylesheet.css)"
fi
fi
if [[ "$missing" != true ]]; then
return 0
fi
warn "Host files under ${DV_DATA_DIR}/ are not visible inside ${container} at /dv/."
if [[ -d "${DV_DATA_DIR}/branding" ]]; then
log "Copying branding and docroot into ${container}..."
if [[ "$DRY_RUN" == true ]]; then
echo "[dry-run] docker cp ${DV_DATA_DIR}/branding ${container}:/dv/branding"
echo "[dry-run] docker cp ${DV_DATA_DIR}/docroot/. ${container}:/dv/docroot/"
else
docker cp "${DV_DATA_DIR}/branding" "${container}:/dv/branding"
run mkdir -p "${DV_DATA_DIR}/docroot"
docker cp "${DV_DATA_DIR}/docroot/." "${container}:/dv/docroot/" 2>/dev/null || true
if docker exec "$container" test -r "$probe" 2>/dev/null; then
log "Copy succeeded: ${probe} is now readable"
else
warn "Copy finished but ${probe} is still missing; check Docker volume mounts."
fi
if [[ "$WITH_DOCROOT" == true ]] && docker exec "$container" test -r "$css_probe" 2>/dev/null; then
log "Copy succeeded: ${css_probe} is now readable"
fi
fi
return 0
fi
echo ""
echo " Fix (from repo root, recreates bind mount for /dv):"
echo " docker compose -f docker-compose-dev.yml down"
echo " docker compose -f docker-compose-dev.yml up -d"
echo ""
}
# --- main ---
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage; exit 0 ;;
--no-apply) SKIP_LOCAL_APPLY=true ;;
--apply-only) APPLY_ONLY=true ;;
--with-theme) WITH_THEME=true ;;
--with-docroot) WITH_DOCROOT=true ;;
--closer) CLOSER_MODE=true; WITH_DOCROOT=true ;;
--skip-analytics) SKIP_ANALYTICS=true ;;
--dry-run) DRY_RUN=true ;;
--yes) ASSUME_YES=true ;;
*) die "Unknown option: $1 (use --help)" ;;
esac
shift
done
require_deps
if [[ "$APPLY_ONLY" == true && "$SKIP_LOCAL_APPLY" == true ]]; then
die "Cannot use --no-apply and --apply-only together"
fi
if [[ "$APPLY_ONLY" != true ]]; then
pull_phase
fi
if [[ "$SKIP_LOCAL_APPLY" != true ]]; then
apply_phase
else
log "Skipped local install (--no-apply). Staged under ${SYNC_DIR}"
log "Run with --apply-only when ready to update ${LOCAL_URL}"
fi
echo ""
log "Done. Staging: ${SYNC_DIR}"
if [[ "$SKIP_LOCAL_APPLY" != true ]]; then
log "Open ${LOCAL_URL} and hard-refresh to verify branding."
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment