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()andupdate()are checked (they resolvefalseinstead of throwing)
Configuration
-
config.versionnames the version you published to (the default isdraft) - Quality validation disabled, or kept on on purpose to report data errors
- CDN URLs verified
Performance
-
@dcupl/coreis 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
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
# Production Configuration
DCUPL_PROJECT_ID=prod-project-id
DCUPL_API_KEY=prod-api-key-xxx
NODE_ENV=productionIn 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)
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:
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();
}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()withoutresourceTagsinrefreshData(): it reloads the model files as well, and thedcupl.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.listsuntillist.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 bothimportandrequire().
Docker container
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:
# 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: 10Security 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 readsdcupl.lc.json,models/**,data/**,transformers/**andoperators/**. It leaves outscripts/**on purpose: the scripts of your REST APIs and workflows live there. Don't addscripts/**to a key that ships in a browser: an app that loadsscriptresources 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 aNEXT_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 Actions example
- name: Deploy
env:
DCUPL_PROJECT_ID: ${{ secrets.DCUPL_PROJECT_ID }}
DCUPL_API_KEY: ${{ secrets.DCUPL_API_KEY }}
run: npm run deploySee 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.comserves your files.api.dcupl.comanswers 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 withnew Function. Leave it out when the app has none, when you turn scripting off withscripting: { enabled: false }(the loader then skips those resources with aScriptingDisablederror), or when you pass your own compiler asscripting.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
failedItemsin the loader progress and theInvalidResourceerrors insummary(). A403on 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.versionnames the version you published to (the default isdraft), 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.