.md

Production deployment

Deploy your dcupl application to production with this checklist and the dcupl-specific parts of a deployment: the production setup, API keys, data loading and refresh, long-running servers, and the Content Security Policy. For error tracking and monitoring, see Production Debugging.

Pre-deployment checklist

Code quality

  • All quality validation errors resolved
  • TypeScript compilation passes without errors
  • Tests passing, including the data quality checks (Testing)
  • The results of init() and update() are checked (they resolve false instead of throwing)

Configuration

  • config.version names the version you published to (the default is draft)
  • Quality validation disabled, or kept on on purpose to report data errors
  • CDN URLs verified

Performance

  • @dcupl/core is bundled once, in one version (each entry bundles its dependencies, so tree shaking can't trim the SDK)
  • Data loading strategy defined
  • Caching strategy implemented
  • Large dataset handling tested

Security

  • The browser bundle carries only a scoped read-only key
  • The CLI key and other keys that can write are only in server environments and CI secrets
  • The project is private: a request for one of its files without one of the project's keys answers 403. New projects are private by default; for an older project, private file serving is part of a paid plan that dcupl turns on for your project; ask your dcupl contact (What the API Key Protects)
  • Content Security Policy allows the dcupl hosts

Production configuration

Optimized dcupl setup

src/config/production.ts
import { Dcupl } from '@dcupl/core';
import { DcuplAppLoader } from '@dcupl/loader';

export function createProductionDcupl() {
  const dcupl = new Dcupl({
    config: {
      projectId: process.env.DCUPL_PROJECT_ID!,
      apiKey: process.env.DCUPL_API_KEY!,
      version: 'production', // The version you published to; the default is 'draft'
    },
    quality: {
      enabled: false, // Disable for performance
    },
  });

  const loader = new DcuplAppLoader();
  dcupl.loaders.add(loader);

  return { dcupl, loader };
}

export async function initializeProduction() {
  const { dcupl, loader } = createProductionDcupl();

  try {
    // Fetch published configuration
    await loader.config.fetch();
    await loader.process();

    // init() doesn't throw: it logs the error and resolves false
    if (!(await dcupl.init())) throw new Error('dcupl.init() failed');

    return dcupl;
  } catch (error) {
    console.error('Failed to initialize dcupl:', error);
    throw error;
  }
}

loader.config.fetch() throws when the loader configuration can't be fetched. A model or data file that fails to download does not make loader.process() or init() return false: the failure is logged and recorded as a loader error (type: 'loader', e.g. InvalidResource) in dcupl.quality.errors.summary(), even with quality disabled. Check for those (or for failedItems in the loader progress, see Capture Loader Errors) before you treat the instance as ready. Load from the console covers the project ID, the key, the version and what each fetch error means.

Environment variables

.env.production
# Production Configuration
DCUPL_PROJECT_ID=prod-project-id
DCUPL_API_KEY=prod-api-key-xxx
NODE_ENV=production

In a browser app, every variable the bundle reads ends up in the bundle: see What the API Key Protects for which key goes there.

Performance optimization

1. Disable quality validation

Quality validation adds overhead. Disable in production:

const dcupl = new Dcupl({
  quality: {
    enabled: false, // Production: disabled
  },
});

Trade-off: with quality disabled, dcupl.quality.errors.summary() returns only loader errors (resources that failed to load) and the $DcuplErrorTrackingErrors model does not exist. Keep it enabled if you report data-quality errors from production (see Production Debugging), and measure the difference on your own data.

2. Use partial updates

Avoid full data reloads when possible:

// Bad: Full reload
dcupl.data.set(allData, { model: 'Product' });
await dcupl.init();

// Good: Partial update — only the changed attributes and what depends on them are reprocessed
dcupl.data.update([{ key: 'p1', price: 999 }], { model: 'Product' });
await dcupl.update();

data.update() only changes items that already exist (unknown keys are ignored); use data.upsert() to add new items in the same call.

update() falls back to a full rebuild when a data.set() or data.reset() is part of the batch, when a model definition changed after init() (models.set(), models.update() or models.remove()), when the changes can alter the result of a query reference, or when more than half of the models and more than half of their items changed. An update() with nothing staged also re-runs the full build. Both thresholds are configurable, see Partial Updates.

3. Implement list pooling

Reuse lists instead of creating new ones:

class ListPool {
  private pool: Map<string, DcuplList[]> = new Map();

  get(dcupl: Dcupl, modelKey: string): DcuplList {
    const pool = this.pool.get(modelKey) || [];
    if (pool.length > 0) {
      return pool.pop()!;
    }
    return dcupl.lists.create({ modelKey });
  }

  release(list: DcuplList) {
    list.catalog.query.clear();
    const modelKey = list.modelKey;
    const pool = this.pool.get(modelKey) || [];
    pool.push(list);
    this.pool.set(modelKey, pool);
  }
}

4. Use pagination

For large result sets:

const productList = dcupl.lists.create({ modelKey: 'Product' });

// Page 1, 50 items (reasonable page size)
productList.catalog.query.applyOptions({ start: 0, count: 50 });
const page = productList.catalog.query.items();

// Total number of matches (count() returns the page length)
const total = productList.catalog.fn.metadata().currentSize;

5. Lazy load non-critical data

Load the resources the first screen needs, render, then load the rest in the background. Tag the resources in your loader configuration and process them by tag. A tag filter skips untagged resources, so tag the model resources too:

// Resources tagged 'critical' (the models and the first data), then the app can render
await loader.process({ resourceTags: ['critical'] });
if (!(await dcupl.init())) throw new Error('dcupl.init() failed');
renderApp(dcupl);

// The rest in the background, applied with update()
void loader.process({ resourceTags: ['optional'] }).then(() => dcupl.update());

A data resource replaces all rows of its model by default (options.updateType is 'set'). When an optional resource feeds the same model as a critical one, give it options: { updateType: 'upsert' }. See Partial Data Loading for a version that reports failed resources.

Error handling

Graceful initialization

export async function initWithFallback() {
  const { dcupl, loader } = createProductionDcupl();

  try {
    await loader.config.fetch();
    await loader.process();
    if (!(await dcupl.init())) throw new Error('dcupl.init() failed');
    return { dcupl, status: 'ready' };
  } catch (error) {
    console.error('dcupl initialization failed:', error);

    // Option 1: Return degraded state
    return { dcupl: null, status: 'error', error };

    // Option 2: Retry with exponential backoff
    // return retryInit();
  }
}

Retry logic

async function retryInit(maxRetries = 3): Promise<Dcupl> {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    // A fresh instance per attempt: the failed attempt's loader errors would
    // otherwise stay in dcupl.quality.errors.summary()
    const { dcupl, loader } = createProductionDcupl();
    try {
      await loader.config.fetch();
      await loader.process();
      if (!(await dcupl.init())) throw new Error('dcupl.init() failed');
      return dcupl;
    } catch (error) {
      console.error(`Init attempt ${attempt} failed:`, error);
      dcupl.destroy();

      if (attempt === maxRetries) {
        throw new Error(`Failed after ${maxRetries} attempts`);
      }

      // Exponential backoff
      await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt - 1)));
    }
  }
  throw new Error('Unreachable');
}

Query error handling

function safeQuery(list: DcuplList) {
  try {
    return list.catalog.query.items();
  } catch (error) {
    console.error('Query failed:', error);
    return []; // Return empty array as fallback
  }
}

Monitoring

Production Debugging covers error tracking with Sentry, startup and query timings, loader failures and data-quality alerts. For a server, add a health check:

Health check endpoint

// Express/Node.js example

// Liveness: the process is up
app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

// Readiness: dcupl is initialized and has data
app.get('/health/dcupl', (req, res) => {
  try {
    const list = dcupl.lists.create({ modelKey: 'Product' }); // throws when the model is missing
    const count = list.catalog.query.count();
    list.destroy();
    if (count === 0) throw new Error('No products loaded');

    res.json({
      status: 'healthy',
      productCount: count,
      timestamp: new Date().toISOString(),
    });
  } catch (error) {
    res.status(503).json({
      status: 'unhealthy',
      error: (error as Error).message,
      timestamp: new Date().toISOString(),
    });
  }
});

/health/dcupl answers 503 when the model is missing or has no items, for example after a failed load. Use it to take the instance out of rotation (a readiness check), not to restart it: a restart doesn't fix a data problem.

Deployment strategies

Static site (Vercel, Netlify)

src/lib/dcupl.ts
import { Dcupl } from '@dcupl/core';
import { DcuplAppLoader } from '@dcupl/loader';

let dcuplInstance: Dcupl | null = null;

export async function getDcupl(): Promise<Dcupl> {
  if (dcuplInstance) {
    return dcuplInstance;
  }

  const dcupl = new Dcupl({
    config: {
      projectId: process.env.NEXT_PUBLIC_DCUPL_PROJECT_ID!,
      // NEXT_PUBLIC_ variables are inlined into the browser bundle: a scoped read-only key only
      apiKey: process.env.NEXT_PUBLIC_DCUPL_API_KEY!,
      version: 'production',
    },
    quality: { enabled: false },
  });

  const loader = new DcuplAppLoader();
  dcupl.loaders.add(loader);

  await loader.config.fetch();
  await loader.process();
  // Don't cache a failed instance: init() resolves false instead of throwing
  if (!(await dcupl.init())) throw new Error('dcupl.init() failed');

  dcuplInstance = dcupl;
  return dcupl;
}

Node.js server and SSR

On a server, build one instance per process and share it between requests. init() parses every model and data file, so an instance per request is slow:

src/server/dcupl.ts
import { Dcupl } from '@dcupl/core';
import { DcuplAppLoader } from '@dcupl/loader';

let instance: Promise<{ dcupl: Dcupl; loader: DcuplAppLoader }> | null = null;

// One instance per process, shared by every request
export function getDcupl() {
  instance ??= (async () => {
    const dcupl = new Dcupl({
      config: {
        projectId: process.env.DCUPL_PROJECT_ID!,
        apiKey: process.env.DCUPL_API_KEY!, // stays on the server
        version: 'production',
      },
    });
    const loader = new DcuplAppLoader();
    dcupl.loaders.add(loader);
    await loader.config.fetch();
    await loader.process();
    if (!(await dcupl.init())) throw new Error('dcupl.init() failed');
    return { dcupl, loader };
  })().catch((error) => {
    instance = null; // let the next request try again
    throw error;
  });
  return instance;
}

// Call it after you publish new data (from a webhook or a timer).
// Reloading only the resources tagged 'data' skips the model files;
// a plain process() also reloads the model files; update() applies their changes.
export async function refreshData(): Promise<boolean> {
  const { dcupl, loader } = await getDcupl();
  await loader.config.fetch(); // also renews the ?v= version parameter of CDN urls
  await loader.process({ resourceTags: ['data'] });
  return dcupl.update();
}
Per request
app.get('/products', async (req, res) => {
  const { dcupl } = await getDcupl();
  const list = dcupl.lists.create({ modelKey: 'Product' });
  try {
    list.catalog.query.addCondition({ attribute: 'category', operator: 'eq', value: String(req.query.category) });
    res.json(list.catalog.query.items({ count: 50 }));
  } finally {
    list.destroy(); // lists stay registered in dcupl.lists until destroyed
  }
});
  • New data: a running instance never picks up data you publish later. Reload the data resources as above (Problem: Stale Data). If the model files changed too, call loader.process() without resourceTags in refreshData(): it reloads the model files as well, and the dcupl.update() after it applies the changed models to the running instance, so you need no new instance.
  • Lists: a list created per request stays in dcupl.lists until list.destroy(). For a one-off query, dcupl.query.execute({ modelKey, queries }) needs no list.
  • Shared results: results are deep clones by default (performance.clone), so a handler that changes a result doesn't change the shared instance. Keep cloning on for a shared instance.
  • Runtime: the loader uses the global fetch (see Compatibility for the Node.js versions). The packages work with both import and require().

Docker container

Dockerfile
FROM node:24-alpine

WORKDIR /app

# Copy package files
COPY package*.json ./
RUN npm ci

# Copy app files
COPY . .

# Build, then drop the dev dependencies the build needed
RUN npm run build && npm prune --omit=dev

# Set environment
ENV NODE_ENV=production

# Run
CMD ["npm", "start"]

Pass DCUPL_PROJECT_ID and DCUPL_API_KEY as environment variables when you start the container, not as ENV lines in the image.

Kubernetes

The dcupl-specific parts of a deployment are the key from a secret and the two health checks:

k8s/deployment.yaml
# spec.template.spec of the Deployment
containers:
  - name: app
    image: your-registry/dcupl-app:latest
    env:
      - name: DCUPL_PROJECT_ID
        valueFrom:
          secretKeyRef:
            name: dcupl-secrets
            key: project-id
      - name: DCUPL_API_KEY
        valueFrom:
          secretKeyRef:
            name: dcupl-secrets
            key: api-key
    livenessProbe:
      httpGet:
        path: /health
        port: 3000
    readinessProbe:
      httpGet:
        path: /health/dcupl
        port: 3000
      initialDelaySeconds: 10
      periodSeconds: 10

Security considerations

What the API key protects

The loader sends the API key with every request to the dcupl CDN, as an api-key query parameter next to the ?v= version parameter. In a browser app, anyone who opens the network tab can read it, and environment variables don't change that: the build inlines them into the bundle. So treat a key that ships to the browser as public. It identifies the project and, on a private project, decides which files can be read. It doesn't hide the data the app loads from the people who use the app.

  • Browser apps use a scoped read-only key whose file read patterns (files.read) cover only what the app loads. The key new projects get for browser apps reads dcupl.lc.json, models/**, data/**, transformers/** and operators/**. It leaves out scripts/** on purpose: the scripts of your REST APIs and workflows live there. Don't add scripts/** to a key that ships in a browser: an app that loads script resources needs its own scoped key, with read patterns for just the scripts it loads. The SDK readonly key works too, but it reads every file of the project. See API keys.
  • Keys that can write, the CLI key and scoped keys with file write patterns (files.write), stay on the server and in CI secrets. Never put them in a browser bundle or a NEXT_PUBLIC_ / VITE_ variable.
  • A private project serves its files only to requests that carry one of the project's keys. On a public project, anyone who knows the URLs can read the files, with or without a key.

New projects are private by default and get a scoped read-only key for browser apps. A project created before projects became private by default stays public: private file serving for it is part of a paid plan that dcupl turns on for your project; ask your dcupl contact. See API keys.

On a server (Node.js, SSR, Docker), the key stays in the server's environment:

// Never hardcode API keys
const dcupl = new Dcupl({
  config: {
    projectId: process.env.DCUPL_PROJECT_ID!, // From environment
    apiKey: process.env.DCUPL_API_KEY!, // From environment
  },
});

Secrets storage

The CLI key belongs in your CI provider's secret store. The CLI reads it from DCUPL_API_KEY:

.github/workflows/deploy.yml
# GitHub Actions example
- name: Deploy
  env:
    DCUPL_PROJECT_ID: ${{ secrets.DCUPL_PROJECT_ID }}
    DCUPL_API_KEY: ${{ secrets.DCUPL_API_KEY }}
  run: npm run deploy

See CI/CD integration for pushing files from CI.

Content Security Policy

The dcupl part of a Content Security Policy:

// Next.js example
const cspHeader = `
  default-src 'self';
  script-src 'self' 'unsafe-eval';
  connect-src 'self' https://cdn.dcupl.com https://api.dcupl.com;
`;
  • connect-src: cdn.dcupl.com serves your files. api.dcupl.com answers the version check the loader makes before loading from the dcupl CDN; it sets the ?v= parameter that keeps browsers and caches from serving old files. Add the hosts of any other resource URLs in your loader configuration.
  • 'unsafe-eval' is only needed when the app runs scripts, custom operators or loader transformers: dcupl compiles them with new Function. Leave it out when the app has none, when you turn scripting off with scripting: { enabled: false } (the loader then skips those resources with a ScriptingDisabled error), or when you pass your own compiler as scripting.compile (Dcupl API).

Post-deployment checklist

Immediate checks

  • Application loads without errors
  • dcupl initialization succeeds
  • dcupl.quality.errors.summary() has no loader errors (type: 'loader')
  • Queries return expected results
  • Health check endpoint responds

Ongoing monitoring

  • Error tracking configured, including console errors (Production Debugging)
  • Alerting for initialization failures
  • A data refresh after each publish, for long-running servers

Rollback plan

  • Previous version tagged and deployable
  • Rollback procedure documented

Troubleshooting production issues

Common Production Problems covers the debug steps and messages for data that doesn't load, slow initialization, memory and stale data. The dcupl-specific checks first:

  • Data doesn't load: check failedItems in the loader progress and the InvalidResource errors in summary(). A 403 on a private project means the key is missing, isn't a key of the project, or doesn't cover that path.
  • Old data after a publish: check that config.version names the version you published to (the default is draft), and reload the data in a long-running process (Problem: Stale Data).
  • Memory grows: check the number of lists (dcupl.lists.getAll().length) and destroy lists you no longer need.