T06-L04

Build apps · Integrator

Real data behind it

T06-L04 · Build apps · Level 4 Integrator · 40 minutes

Level
IntegratorLevel 4 of 5
Curriculum position
Family 2 · Track 06
Reading time
40 minutes
Reading progress
0%Time on this book
Last revised
Sep 5, 2026

T06-L04 · Build apps · Level 4 Integrator · 40 minutes

2. The prototype became the only copy

You open the tracker from T06-L03 and find that a group now relies on it. In the Lab version, it holds the week's PCR equipment bookings. In the Company version, it holds shared equipment reservations. The interface still lives in a builder project tied to one person's email, its storage cannot be explained, and the browser bundle contains a long-lived API token added during a rushed integration.

Then a generated schema change removes a field. There is no reviewed migration, no independent database backup, and no measured restore. Several people may lose bookings, another group may see records outside its scope, and nobody knows whether exporting the visible table would recover roles, policies, or history.

You need to separate the app from its data layer. Put the records behind an organisation-owned Directus API and PostgreSQL database, enforce group access in Directus, keep service credentials on the server, and version every shape change. Finally, delete only a disposable restore target and rebuild it from a database backup. The restore, not the existence of a backup file, is the evidence that the data can survive the original builder account.

3. After this you can

  • Place an organisation-owned database and API behind an existing app.
  • Enforce role, field, and group permissions in the data layer rather than only in the interface.
  • Keep administrative and service credentials out of browser code, repositories, logs, and screenshots.
  • Promote a data-model change through a reviewed snapshot or migration instead of editing production first.
  • Back up a Directus/PostgreSQL test project and prove recovery by restoring an empty disposable database.

4. Prerequisites

  • T06-L03 · An app your colleagues actually use, including its two-role policy, server-side validation, and two-account tests.
  • T12-L04 · Secure an AI system, including secret storage, least privilege, logging, and response ownership.
  • An organisation-controlled Linux host or approved test environment with Docker Compose, current supported Docker tooling, and enough space for two copies of the synthetic database during restore.
  • Reviewed pinned Directus and PostgreSQL image references that are compatible with each other and with the target environment.
  • An approved secret store for a real deployment; for this local exercise, an access-restricted, untracked environment file containing synthetic test credentials.
  • Two ordinary Directus test users in different groups, one non-person application path, and one separate recovery owner.
  • curl and jq for API checks, plus permission to stop and destroy only the disposable training database.
  • About 120 minutes for the independent build and restore.

Use only invented booking records, resource names, groups, account labels, and credentials created for this disposable environment. Do not copy real bookings, sample identifiers, customer or employee data, production configuration, access tokens, database dumps, uploads, or unpublished work into the exercise. Never rehearse deletion against production. If the target cannot prove that it is disposable before the destructive command, stop.

5. The idea in one page

The interface, API, database, files, configuration, and backups are different assets with different owners:

browser -> app server -> Directus API -> PostgreSQL
             |               |
        opaque session   roles + policies
                             |
                             +-> file storage, if used

reviewed config + schema change -> staging -> production
database + files + config backup -> isolated restore -> checks

The browser is not a trusted data boundary. It may hide a control, but a user can change a request. Directus must decide whether the authenticated user may create, read, update, or delete this item and which fields may cross the API. In Directus, permissions attach to policies and roles; item filters and field permissions narrow access. Multiple policies can add access, so inspect the complete effective set. A status value such as draft or requested is data, not secrecy.

Keep secrets where the component that needs them can read them and the browser cannot. An administrator password and Directus signing secret belong in the deployment secret store. A backend-for-frontend may keep a user's Directus session server-side and give the browser only an opaque, Secure, HttpOnly session cookie. A static administrator token must never appear in frontend source, build-time public variables, local storage, analytics, or network responses. If the browser calls Directus directly, it uses the ordinary user's short-lived session and Directus still enforces that user's policy; it does not receive a shared privileged token.

Treat data-model changes as releases. Create or modify collections in development, capture the proposed schema, inspect the diff against staging, apply it there, and run API and permission tests. Back up before a production migration. Directus schema promotion covers the data model; it is not a substitute for backing up records, system configuration, permissions, or files.

A complete recovery set includes the PostgreSQL database, uploaded files when used, extensions, deployment configuration, image references, secret references, and restore instructions. Keep it outside the original host or account failure domain under approved encryption and access controls. A successful pg_dump only creates a candidate backup. Recovery is proven when a fresh empty database accepts it and the restored API returns the expected rows while preserving denials.

6. The worked example: move one booking slice to a recoverable backend

Build one common bookings backend. The Lab framing calls the resources PCR instruments; the Company framing calls them shared equipment. Both use synthetic records, two groups, and the same role and restore tests. The app remains a test app until production architecture, privacy, availability, and support review are complete.

Record ownership and recovery before deployment

Create this one-page deployment record:

Service: Synthetic Booking API
Business owner: [role]
Technical owner: [role]
Recovery owner: [different authorised person]
Data owner: [role]
Environment: disposable training only
Directus image: [reviewed pinned image reference]
PostgreSQL image: [reviewed pinned image reference]
Database location: [named test host and volume]
File storage: local uploads directory; no uploads permitted in this exercise
Secret location: [approved store or protected local test file]
Backup location: [approved location outside the database volume]
Restore target label: directus-restore-training
Maximum acceptable recovery time for this test: 30 minutes
Stop control: docker compose stop directus
Deletion guard: RESTORE_TARGET must equal disposable_training_only

Do not continue if the service and backup share the same only copy, the project belongs only to a personal account, or the recovery owner cannot obtain the reviewed configuration and secret references.

Start a pinned, persistent test data layer

Create an empty authorised directory with uploads, extensions, snapshots, backups, and restore-input subdirectories. backups is only a staging location for the newly made candidate; restore-input will later receive a verified copy retrieved from independent off-host retention. Keep both, along with uploads and extensions, out of a public repository. Save this as compose.yaml:

services:
  database:
    image: ${POSTGRES_IMAGE}
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: ${DB_DATABASE}
      RESTORE_TARGET: ${RESTORE_TARGET}
    volumes:
      - directus-db:/var/lib/postgresql/data
      - ./backups:/backups
      - ./restore-input:/restore-input:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 10
    networks: [data-private]

  directus:
    image: ${DIRECTUS_IMAGE}
    restart: unless-stopped
    depends_on:
      database:
        condition: service_healthy
    environment:
      SECRET: ${DIRECTUS_SECRET}
      DB_CLIENT: pg
      DB_HOST: database
      DB_PORT: 5432
      DB_DATABASE: ${DB_DATABASE}
      DB_USER: ${DB_USER}
      DB_PASSWORD: ${DB_PASSWORD}
      PUBLIC_URL: ${DIRECTUS_PUBLIC_URL}
      ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
      ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
      TELEMETRY: "false"
    ports:
      - "127.0.0.1:8055:8055"
    volumes:
      - ./uploads:/directus/uploads
      - ./extensions:/directus/extensions
      - ./snapshots:/directus/snapshots
    networks: [data-private]

networks:
  data-private:

volumes:
  directus-db:

The loopback binding is for this local exercise, not a production ingress design. A production app requires the approved HTTPS gateway, network policy, monitoring, and availability design. PostgreSQL publishes no host port here. TELEMETRY: false is a deliberate test boundary, not a complete egress control.

Create an untracked .env readable only by the deployment owner:

DIRECTUS_IMAGE=directus/directus:[reviewed-pinned-release]
POSTGRES_IMAGE=postgres:[reviewed-supported-release]
DB_USER=directus_training
DB_PASSWORD=[generated-test-database-password]
DB_DATABASE=directus_training
DIRECTUS_SECRET=[generated-long-random-test-secret]
DIRECTUS_PUBLIC_URL=http://127.0.0.1:8055
DIRECTUS_ADMIN_EMAIL=[approved-test-admin-inbox]
DIRECTUS_ADMIN_PASSWORD=[generated-test-admin-password]
RESTORE_TARGET=disposable_training_only

Replace every bracketed value. Do not use latest, tutorial passwords, or personal email. Add .env, backups/, restore-input/, and uploads/ to the repository ignore rules in the real deployment repository. Validate without rendering secrets into shared logs:

docker compose config --quiet
docker compose pull
docker compose up -d
docker compose ps
curl --fail --silent http://127.0.0.1:8055/server/ping

Expected final output from the health request is:

pong

Record the resolved image digests with docker image inspect through the approved deployment process. After the initial administrator exists, remove bootstrap ADMIN_EMAIL and ADMIN_PASSWORD from the deployment inputs and secret store if your deployment process no longer needs them, then restart and verify administrator login through the normal identity path. Keep DIRECTUS_SECRET stable and protected; changing it can invalidate sessions.

Model one booking without putting authority in the form

The permission recipe below is bounded to Directus major version 11. Choose one supported 11.x patch, pin its immutable image digest in .env, and record that exact patch and digest. Confirm the running CLI reports the recorded 11.x patch before configuring permissions:

docker compose exec -T directus npx directus --version

Stop if it reports v10, v12, or a patch other than the reviewed one. Directus permission storage and dynamic-variable behaviour are release contracts: after every patch upgrade, apply the schema to staging and rerun all allowed and denied requests below before promotion. The names, relationship directions, and JSON in this recipe are for Directus 11.x; they are not generic pseudocode.

In Directus Data Studio, create a collection called bookings with a generated UUID primary key and these fields:

FieldType and constraintAuthority
resource_codestring, required, 3-40 charactersuser chooses from approved resource list
scopemany-to-one relationship to booking_scopes, requiredpreset from authenticated user's relationship; not editable
starts_attimestamp, requireduser input, validated
ends_attimestamp, required and later than startuser input, validated
purposeplain string, required, 5-120 charactersuser input; no real detail in exercise
statusRequested, Approved, or Cancelledpreset to Requested; coordinator changes
user_createdDirectus accountability fieldplatform sets from authenticated user
date_createdDirectus accountability fieldplatform sets

Create booking_scopes first, with a generated UUID id and a unique required string code. Add exactly two synthetic items, SYN-CEDAR and SYN-ORBIT. Then add these two many-to-one relationships in Data Model:

  1. directus_users.booking_scopebooking_scopes.id, required for the four test users and writable only by an administrator.
  2. bookings.scopebooking_scopes.id, required, with no reverse alias needed for this exercise.

Assign each ordinary user one booking_scope; do not grant ordinary users create, update, or delete on booking_scopes, or field update on directus_users.booking_scope. This relationship design makes the same UUID available to the booking and $CURRENT_USER.booking_scope; it avoids comparing a browser-supplied label. Do not accept scope, status, or user_created from the browser create form. Directus 11 field validation does not reliably express a comparison between two fields in this permission recipe, so enforce time order with a reviewed PostgreSQL CHECK (ends_at > starts_at) migration (or a reviewed server endpoint) rather than claiming the browser enforces it.

Create two roles: Booker and Coordinator. Their effective policies are:

RoleReadCreateUpdateDelete
Bookerrows where scope equals the current user's booking_scopepermitted fields; trusted scope and Requested presetown future Requested row, purpose and times onlynone
Coordinatorrows where scope equals the current user's booking_scopenonesame-scope status onlynone
Publicnonenonenonenone

In Settings → Access Control, create one Booker policy and one Coordinator policy. For bookings, choose custom permission for each stated action and enter the following exact Directus 11 rules in the advanced JSON editor. “Fields” means the action's field allowlist; an omitted field is denied. Leave every unlisted action at None.

Policy/actionItem permission JSONValidation JSONPreset JSONFields
Booker/read{"scope":{"_eq":"$CURRENT_USER.booking_scope"}}{}{}id,resource_code,scope,starts_at,ends_at,purpose,status,user_created,date_created
Booker/create{}{"_and":[{"resource_code":{"_nnull":true}},{"starts_at":{"_nnull":true}},{"ends_at":{"_nnull":true}},{"purpose":{"_nnull":true}}]}{"scope":"$CURRENT_USER.booking_scope","status":"Requested","user_created":"$CURRENT_USER"}resource_code,starts_at,ends_at,purpose
Booker/update{"_and":[{"scope":{"_eq":"$CURRENT_USER.booking_scope"}},{"user_created":{"_eq":"$CURRENT_USER"}},{"status":{"_eq":"Requested"}},{"starts_at":{"_gt":"$NOW"}}]}{}{}starts_at,ends_at,purpose
Coordinator/read{"scope":{"_eq":"$CURRENT_USER.booking_scope"}}{}{}id,resource_code,scope,starts_at,ends_at,purpose,status,user_created,date_created
Coordinator/update{"scope":{"_eq":"$CURRENT_USER.booking_scope"}}{"status":{"_in":["Approved","Cancelled"]}}{}status

In the Studio rule builder, $CURRENT_USER.booking_scope means the booking_scope field on the authenticated directus_users item, while $CURRENT_USER alone resolves to that user's UUID. Presets are trusted defaults, not an additional browser field grant: the Booker create allowlist intentionally excludes scope, status, and user_created. Make the database constraints for required values, string lengths, status choices, and ends_at > starts_at part of the reviewed migration as defence in depth.

Policies are additive. Inspect the policy attached directly to each test user, the policy attached to its role, and inherited parent-role policies. A second broad read or field grant defeats this design. Export the sanitized schema/policy state through the approved Directus 11 environment-sync path, review it, and rerun the tests after applying it to a fresh staging project; do not rely on a screenshot of the rule builder.

Create four ordinary synthetic identities through the administrator path: one Booker and one Coordinator in SYN-CEDAR, and one of each in SYN-ORBIT. No ordinary identity receives administrator access, policy editing, schema access, static token management, or a second broad role.

Lab framing: a PCR booking calendar

Create this row as the Cedar Booker through the API or app server:

{
  "resource_code": "PCR-SYN-01",
  "starts_at": "2026-10-12T09:00:00Z",
  "ends_at": "2026-10-12T10:00:00Z",
  "purpose": "Synthetic calibration practice"
}

The stored row must relate scope to the SYN-CEDAR scope item and add status: Requested, creator, and creation time from trusted context. The Cedar Coordinator may read it and change only status. The Orbit accounts must not retrieve it even when they know its UUID. This calendar does not approve an experiment, reserve a real instrument, or establish sample-handling rules.

Company framing: a shared equipment booking

Keep the collection and permissions unchanged. Use EQUIP-SYN-01 and purpose Synthetic projector setup practice. The group remains SYN-CEDAR; SYN-ORBIT is the outside team. The Company Coordinator may approve the booking status but may not rewrite its purpose or scope. The app must not send invites, purchase equipment, charge a cost centre, or connect a real calendar.

Put the API behind the app without shipping a master key

The safest small integration preserves the ordinary Directus user's accountability. Use an app server or backend-for-frontend to establish the user session. Store Directus access and refresh material server-side under the approved session mechanism, and return only an opaque Secure, HttpOnly, SameSite cookie to the browser. For each request, the server calls Directus as that user, not as an administrator. If direct browser-to-Directus sessions are approved instead, use Directus's documented user authentication and short-lived session handling; never compile a static token into the app.

Search the built frontend output before deployment:

FRONTEND_BUILD_DIR=dist  # replace with the one directory your build actually creates
test -d "$FRONTEND_BUILD_DIR"
if rg -n "DIRECTUS_(TOKEN|SECRET)|DB_PASSWORD|Bearer [A-Za-z0-9._-]{16,}" \
  "$FRONTEND_BUILD_DIR"; then
  echo "FAIL: secret-shaped value found in frontend output"
  exit 1
else
  scan_status=$?
  if [ "$scan_status" -ne 1 ]; then
    echo "FAIL: frontend scan did not complete"
    exit "$scan_status"
  fi
fi
echo "PASS: no secret-shaped value found in frontend output"

Expected output is PASS: no secret-shaped value found in frontend output. Exit status 1 from rg means no match; any other scan error fails the wrapper. Also inspect browser network requests and storage. A user access token may exist in an approved direct-session design, but no administrator token, database password, Directus secret, or shared service credential may be returned. Do not print the rendered Compose configuration to prove this check; it contains substituted values.

Use ordinary short-lived bearer tokens supplied through the approved test secret mechanism for command-line permission checks. Do not type them into shell history. Set BOOKING_ID from the Cedar Booker's successful create response, and set CEDAR_SCOPE_ID to the recorded UUID of SYN-CEDAR. Then run the complete positive and negative suite. It fails closed on an unexpected HTTP status, leaked booking content, wrong preset, or changed forbidden field:

set -eu
API=http://127.0.0.1:8055
test -n "$BOOKING_ID" && test -n "$CEDAR_SCOPE_ID"
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT

# Allowed list: exactly the Cedar synthetic row and trusted presets.
curl --globoff --fail --silent \
  --header "Authorization: Bearer $CEDAR_BOOKER_TOKEN" \
  "$API/items/bookings?filter[id][_eq]=$BOOKING_ID&fields=id,resource_code,scope,status,purpose" \
  >"$work/cedar.json"
jq -e --arg id "$BOOKING_ID" --arg scope "$CEDAR_SCOPE_ID" '
  .data as $rows |
  ($rows | length) == 1 and $rows[0].id == $id and
  $rows[0].scope == $scope and $rows[0].status == "Requested" and
  $rows[0].resource_code == "PCR-SYN-01"
' "$work/cedar.json" >/dev/null

# Denied list: Orbit receives no Cedar item.
curl --globoff --fail --silent \
  --header "Authorization: Bearer $ORBIT_BOOKER_TOKEN" \
  "$API/items/bookings?filter[id][_eq]=$BOOKING_ID&fields=id,resource_code,scope,status,purpose" \
  | jq -e '.data | length == 0' >/dev/null

# Denied known-ID read: accept the reviewed concealment status, but no fields may leak.
orbit_read_http=$(curl --globoff --silent --output "$work/orbit-read.json" \
  --write-out '%{http_code}' \
  --header "Authorization: Bearer $ORBIT_BOOKER_TOKEN" \
  "$API/items/bookings/$BOOKING_ID?fields=id,resource_code,scope,status,purpose")
case "$orbit_read_http" in 403|404) ;; *) echo "FAIL Orbit read: HTTP $orbit_read_http"; exit 1;; esac
if jq -e '.. | strings | select(. == "PCR-SYN-01" or . == "Synthetic calibration practice")' \
  "$work/orbit-read.json" >/dev/null; then
  echo "FAIL: denied response leaked booking content"; exit 1
fi

# Denied cross-group write: Orbit Coordinator cannot approve Cedar's item.
orbit_write_http=$(curl --globoff --silent --output "$work/orbit-write.json" \
  --write-out '%{http_code}' --request PATCH \
  --header "Authorization: Bearer $ORBIT_COORDINATOR_TOKEN" \
  --header 'Content-Type: application/json' --data '{"status":"Approved"}' \
  "$API/items/bookings/$BOOKING_ID")
case "$orbit_write_http" in 403|404) ;; *) echo "FAIL Orbit write: HTTP $orbit_write_http"; exit 1;; esac

# Denied field: even the Cedar Coordinator cannot combine status with purpose.
mixed_http=$(curl --globoff --silent --output "$work/mixed-write.json" \
  --write-out '%{http_code}' --request PATCH \
  --header "Authorization: Bearer $CEDAR_COORDINATOR_TOKEN" \
  --header 'Content-Type: application/json' \
  --data '{"status":"Approved","purpose":"FORBIDDEN CHANGE"}' \
  "$API/items/bookings/$BOOKING_ID")
case "$mixed_http" in 400|403) ;; *) echo "FAIL mixed-field write: HTTP $mixed_http"; exit 1;; esac

# Authoritative post-state: neither denied request changed either field.
curl --globoff --fail --silent \
  --header "Authorization: Bearer $CEDAR_COORDINATOR_TOKEN" \
  "$API/items/bookings/$BOOKING_ID?fields=id,status,purpose" \
  | jq -e '.data.status == "Requested" and
           .data.purpose == "Synthetic calibration practice"' >/dev/null

# Public remains closed.
public_http=$(curl --globoff --silent --output "$work/public.json" \
  --write-out '%{http_code}' "$API/items/bookings/$BOOKING_ID")
case "$public_http" in 401|403|404) ;; *) echo "FAIL public read: HTTP $public_http"; exit 1;; esac

echo "PASS Cedar=1 Orbit-list=0 Orbit-read=$orbit_read_http Orbit-write=$orbit_write_http mixed=$mixed_http public=$public_http post-state=unchanged"

The final line is the only expected console summary; exact denial status is allowed to vary only among the values explicitly accepted by the reviewed contract. Store the response files in the controlled evidence record before the trap removes them if local policy requires raw output.

Here is an accessible, annotated evidence specimen for the worked example. It is text rather than a volatile product screenshot, so it remains searchable, zoomable, and screen-reader friendly. It uses only the synthetic identifiers above. Replace its status placeholders with the statuses your run captured; do not present this specimen as observed evidence.

Evidence lineSynthetic result to captureWhat it demonstrates
E1Cedar list: HTTP 200; count 1; PCR-SYN-01; scope=<recorded SYN-CEDAR UUID>; status=RequestedThe related user scope and create presets produced the expected row.
E2Orbit list: HTTP 200; count 0The item filter hides Cedar rows from the other group.
E3Orbit known-ID read: HTTP <403 or 404>; leaked fields noneGuessing an ID does not bypass the relationship filter.
E4Orbit status PATCH: HTTP <403 or 404>; Cedar post-state RequestedThe cross-group update is denied and did not mutate data.
E5Cedar mixed-field PATCH: HTTP <400 or 403>; purpose unchangedThe Coordinator field allowlist rejects purpose, including in a mixed request.
E6Public known-ID read: HTTP <401, 403, or 404>; leaked fields nonePublic permissions remain off.

Provenance and review: course-authored text specimen derived from this synthetic request suite; no production interface, person, token, or third-party image is reproduced. Privacy review: synthetic labels only. Staleness review: rerun and update it whenever the pinned Directus 11 patch or API denial contract changes. This table is supporting evidence inside the one restore record, not a second exit artifact.

Version the data-model change

Do not add a production field directly in Data Studio. Make the change in development, then use the Directus Schema API or current SDK to capture a snapshot, compare it with staging, and inspect the diff. Directus's current promotion guidance notes that mirror mode can mark target-only structures for deletion; use merge mode only when an additive change is intended, and never use a force option merely to bypass a version or database-vendor mismatch.

For a self-hosted reviewed workflow, a compatible Directus CLI may apply an approved snapshot:

docker compose exec -T directus \
  npx directus schema snapshot /directus/snapshots/bookings.yaml

# Run only against the separately identified staging service:
npx directus schema apply ./snapshots/bookings.yaml

The second command is illustrative of the staging release step and must run with staging configuration, not the local production-like service by accident. Review the current CLI/API contract for the pinned Directus release. Commit the sanitized schema description in the deployment repository, not credentials or content. A schema snapshot describes structure; the database backup below remains the recovery source for Directus system tables, roles, policies, and records.

Back up the database and inspect the candidate

This exercise has no uploads, but retain the empty uploads path in the recovery record. In a real service, back up the configured file store consistently with its database metadata and include reviewed extensions. Stop writes for the small exercise so the database and file set have a clear boundary:

docker compose stop directus
docker compose exec -T database sh -eu -c '
  pg_dump --username="$POSTGRES_USER" --dbname="$POSTGRES_DB" \
    --format=custom --file=/backups/directus-training.dump
  pg_restore --list /backups/directus-training.dump >/dev/null
'
docker compose start directus
curl --fail --silent http://127.0.0.1:8055/server/ping

Expected final output is pong, and backups/directus-training.dump must be non-empty. Hash it, record the hash and creation time, encrypt it with the approved backup process, and transfer the retained copy to storage outside both the database volume and source host/account failure domain. Record the off-host object identifier, transfer result, encryption/key reference, retention rule, and recovery owner's independent access. pg_restore --list proves that the local candidate can be read, not that the retained copy can be retrieved or recovered.

This rehearsal restores the independently retained off-host copy, not the convenient dump still mounted at /backups. After the off-host transfer is verified, remove the local candidate. Have the recovery owner—not the database operator's personal account—retrieve and decrypt the retained object into the empty local restore-input directory using the organisation's approved transfer tool. Record that exact retrieval command in the restore record, then verify the retrieved bytes against the previously recorded hash:

# BACKUP_SHA256 is copied from the signed backup record, not recalculated
# from the retrieved file. OFFHOST_OBJECT_ID identifies independent storage.
test -n "$BACKUP_SHA256" && test -n "$OFFHOST_OBJECT_ID"
rm -f ./backups/directus-training.dump
test ! -e ./backups/directus-training.dump
rm -f ./restore-input/directus-training.dump
test ! -e ./restore-input/directus-training.dump

# Run the recorded approved retrieve/decrypt command here. Its destination is:
test -s ./restore-input/directus-training.dump
printf '%s  %s\n' "$BACKUP_SHA256" \
  './restore-input/directus-training.dump' | sha256sum --check --status
docker compose exec -T database \
  pg_restore --list /restore-input/directus-training.dump >/dev/null
echo "PASS: independently retained object retrieved and verified"

Do not continue if the off-host identifier is absent, retrieval used the source-host staging copy, the recovery owner cannot access the retained object, decryption fails, or the recorded hash differs. This exercise proves recovery from that retrieved database archive and the recorded configuration. Because uploads are disabled, it does not claim recovery of file content.

Delete only the disposable target and restore it

Record the start time. Verify the deployment record, current directory, Compose project, database name, and RESTORE_TARGET. Confirm all records are synthetic. Stop Directus, then run this guarded command only in the disposable training stack:

docker compose stop directus
docker compose exec -T database sh -eu -c '
  test "$RESTORE_TARGET" = "disposable_training_only"
  test "$POSTGRES_DB" = "directus_training"
  dropdb --username="$POSTGRES_USER" --maintenance-db=postgres \
    --if-exists "$POSTGRES_DB"
  createdb --username="$POSTGRES_USER" --maintenance-db=postgres \
    --template=template0 "$POSTGRES_DB"
  pg_restore --username="$POSTGRES_USER" --dbname="$POSTGRES_DB" \
    --exit-on-error --single-transaction /restore-input/directus-training.dump
'
docker compose start directus
curl --fail --silent http://127.0.0.1:8055/server/ping

Expected final output is pong. If either guard fails, the shell exits before deletion. If restore fails, do not point the app at the empty target or retry with flags that ignore errors. Keep the service stopped, retain logs under the approved policy, and diagnose image compatibility, archive integrity, ownership, free space, and database extensions.

Repeat the complete positive and negative API suite, using the restored booking ID. Cedar must again receive one synthetic booking and Orbit zero. Verify the Cedar Coordinator can make the permitted status change and cannot change scope or purpose. Confirm public access remains empty, ordinary roles remain non-admin, and no static master credential appears in the frontend. Record the stop time and elapsed recovery time. Only the verified retrieval plus these post-restore behaviour checks prove that this database archive and its data-layer permissions returned; they do not prove recovery of components omitted from the recovery set.

7. What goes wrong

A key is compiled into the frontend

Symptom: a Directus static token, database password, or signing secret appears in JavaScript, source maps, browser storage, or network responses.

Fix: revoke and rotate the exposed credential, remove it from history and build inputs through the incident process, and move calls behind user-scoped authentication or the approved server secret boundary.

The interface is the permission system

Symptom: Orbit cannot see the Cedar button, but a direct /items/bookings request returns Cedar fields or accepts a status change.

Fix: enforce item and field permissions in Directus for the authenticated user. Test list, known ID, create, and update requests from ordinary sessions and inspect stored post-state.

Policies add an unexpected broad grant

Symptom: the narrow group filter looks correct, yet a second attached policy allows all rows or fields.

Fix: inspect every effective user and role policy. Remove unnecessary grants and rerun cross-group and public tests; do not add a supposedly restrictive policy to cancel an additive one.

Production is edited first

Symptom: a field type or relation changes in Data Studio and the frontend breaks before anyone can reproduce or reverse the shape.

Fix: originate changes in development, save the reviewed snapshot or migration, inspect the staging diff, back up, apply the same change, and run compatibility tests before promotion.

The database is backed up but files are not

Symptom: restored records point to missing uploads, or local files survive only on the failed host.

Fix: inventory every storage adapter, back up file objects and metadata consistently, retain configuration and extensions, and verify representative files during an isolated restore.

A backup file is called recovery

Symptom: pg_dump succeeded, but no one knows whether the archive, image versions, roles, or permissions can rebuild a working service.

Fix: restore into an empty disposable target with --exit-on-error, then rerun health, row, role, field, and denial checks and record elapsed time.

The restore rehearsal targets the live database

Symptom: a copied command contains a production project name or relies on the operator noticing the wrong terminal.

Fix: use an isolated restore target, hard-coded disposable labels and database guards, a second-person check, and separate credentials that cannot delete production.

8. Do it yourself: rebuild the data layer in 120 minutes

Minutes 0-10: choose Lab or Company. Name service, data, technical, and recovery owners. Record the disposable target, stop control, recovery-time objective, secret location, backup location, and destructive-command guards.

Minutes 10-25: create the persistent Compose stack with reviewed pinned images, private database network, loopback Directus port, protected environment file, and separate database, uploads, snapshots, and backup paths. Validate and record image digests without printing secrets.

Minutes 25-42: create bookings, fields, validation, accountability fields, and status values. Create two groups and ordinary Booker and Coordinator identities. Keep public permissions off and remove broad policies.

Minutes 42-58: configure item presets, group filters, and field-level create/update permissions. Create one synthetic booking from the correct ordinary role. Prove the stored scope, status, creator, and timestamps came from trusted context.

Minutes 58-72: connect the existing app through user-scoped authentication or an approved backend-for-frontend. Search the built output and browser traffic for privileged credentials. Test Cedar allowed access, Orbit denial, public denial, permitted status update, and forbidden-field update.

Minutes 72-84: capture the development schema, compare it with the separate staging target, inspect destructive operations, and apply the reviewed change in staging. Rerun app and permission checks there.

Minutes 84-94: stop writes, create the custom-format PostgreSQL dump, list its contents, hash it, and transfer the retained encrypted copy outside the source volume, host, and account failure domain. Record its object ID and the independent recovery owner's access. Remove the local candidate, retrieve and decrypt the retained object into restore-input, and match it to the previously recorded hash. Record file-storage and extension decisions even when both are empty.

Minutes 94-108: have the recovery owner verify the target labels and guards. Delete only directus_training, recreate it from template0, restore with error stopping and one transaction, then restart Directus.

Minutes 108-117: rerun health, record count, exact synthetic row, Cedar read, Orbit denial, public denial, Coordinator update, and forbidden-field checks. Compare the restored image and schema references with the backup record.

Minutes 117-120: record elapsed time, result, deviations, and next repair owner. Remove command-line test tokens and temporary sessions, leave no public route, and retain or delete the synthetic environment under the approved policy.

9. Exit check

Deliver exactly one artifact: one completed restore record showing that the disposable database was deleted and rebuilt from backup, with elapsed time recorded.

It passes when the record identifies the source and restore target, pinned image references and digests, schema reference, backup time and hash, off-host object ID and retrieval result, database and file-storage scope, deletion guards, start and finish times, restore command result, health result, exact synthetic row check, Cedar allowed result, Orbit and public denied results, permitted and forbidden field post-state, credential scan result, recovery owner, and final pass, revise, or escalate decision. Command output and API checks are embedded evidence inside this single record. It fails if deletion occurred without both guards, restore read the source-host /backups candidate instead of a hash-verified independently retained copy, the API starts but records or permissions are wrong, a privileged credential appears in the frontend, real data is used, or the original builder is the only person able to restore it.

10. Rule to remember

A backup you have never restored is a hope.

11. Further reading & tools