Deployment
You can deploy your Crowdin app to any hosting provider that supports Node.js. Below you can find examples of how to deploy your Crowdin app to Vercel, AWS Lambda, and Cloudflare Workers.
Vercel
Section titled “Vercel”Vercel can deploy your Crowdin app from existing repo (GitHub, Gitlab, Bitbucket).
Vercel Configuration
Section titled “Vercel Configuration”Go to Vercel and proceed with next steps:
- Create a new project.
- Under the
Environment Variablessection, define following variables:CLIENT_ID- Crowdin App Client ID.CLIENT_SECRET- Crowdin App Client Secret.
- Add Postgres SQL Storage.
- Storage -> Connect Store -> Create New -> Select Postgres.
- Deploy.
Example of the Crowdin app
Section titled “Example of the Crowdin app”Project structure:
Directoryroot
- index.js
- logo.png
- package.json
- package-lock.json
- vercel.json
- .gitignore
App configuration:
import crowdinModule from '@crowdin/app-project-module';
crowdinModule.createApp({ baseUrl: `https://${process.env.VERCEL_URL}`, clientId: process.env.CLIENT_ID, clientSecret: process.env.CLIENT_SECRET, name: 'Sample App', identifier: 'sample-app', description: 'Sample App description', postgreConfig: { host: process.env.POSTGRES_HOST, user: process.env.POSTGRES_USER, password: process.env.POSTGRES_PASSWORD, database: process.env.POSTGRES_DATABASE, ssl: true }, imagePath: import.meta.dirname + '/logo.png', customMT: [ { translate: () => { console.log('translate'); }, validate: () => { console.log('validate'); } } ]});{ "version": 2, "builds": [ { "src": "./index.js", "use": "@vercel/node" } ], "routes": [ { "src": "/(.*)", "dest": "/" } ]}/node_modules/{ "name": "crowdin-app-demo", "version": "1.0.0", "description": "", "main": "index.js", "type": "module", "engines": { "node": "20.x" }, "scripts": { "start": "node index.js" }, "author": "", "license": "ISC", "dependencies": { "@crowdin/app-project-module": "^1.0.0" }}AWS Lambda
Section titled “AWS Lambda”You can use Serverless to deploy your Crowdin app to AWS Lambda.
Example of the Crowdin app
Section titled “Example of the Crowdin app”It uses Serverless framework so in order to deploy it to AWS you just need to install deps npm i and after run this command serverless deploy.
In this example we are using PostgreSQL as a storage (e.g. AWS RDS with PostgreSQL engine can be used). Visit the Database page to see other options.
Project structure:
Directoryroot
- handler.js
- logo.svg
- package.json
- env.json
- package-lock.json
- serverless.yml
- .gitignore
const serverless = require('serverless-http');const path = require('path');const express = require('express');const app = express();const crowdinApp = require('@crowdin/app-project-module');
crowdinApp.addCrowdinEndpoints(app, { baseUrl: process.env.LAMBDA_URL, clientId: process.env.CLIENT_ID, clientSecret: process.env.CLIENT_SECRET, name: 'Sample App', identifier: 'sample-app', description: 'Sample App description', postgreConfig: { user: process.env.PG_USER, password: process.env.PG_PASSWORD, database: process.env.PG_DB, host: process.env.PG_HOST }, imagePath: path.join(__dirname, 'logo.svg'), projectIntegration: { getIntegrationFiles: async () => { return [ { id: '12', name: 'File from integration', type: 'json', parentId: '10' }, { id: '14', name: 'File from integration 2', type: 'xml', parentId: '11' }, { id: '10', name: 'Folder from integration' }, { id: '11', name: 'Folder from integration 2' } ]; }, updateCrowdin: async () => { //TODO implement }, updateIntegration: async () => { //TODO implement } }});
module.exports.handler = serverless(app);service: aws-node-express-apiframeworkVersion: '3'
provider: name: aws runtime: nodejs20.x
functions: api: handler: handler.handler environment: ${file(env.json)} url: true{ "LAMBDA_URL": "https://<id>.execute-api.us-east-1.amazonaws.com", "CLIENT_ID": "<client-id>", "CLIENT_SECRET": "<client-secret>", "PG_HOST": "<pg_host>", "PG_USER": "<pg_user>", "PG_PASSWORD": "<pg_pass>", "PG_DB": "<pg_db>"}node_modules.serverless{ "name": "crowdin-serverless", "version": "1.0.0", "description": "", "dependencies": { "express": "^5.0.0", "serverless-http": "^4.0.0", "@crowdin/app-project-module": "^1.0.0" }, "author": "", "license": "ISC"}Cloudflare Workers
Section titled “Cloudflare Workers”Deploy your Crowdin app to Cloudflare Workers with the @crowdin/app-project-module/cloudflare adapter, a D1 database, and Workers Static Assets. The adapter turns the bindings into the app configuration and runs the module’s scheduled tasks, so the Worker needs one small entry file. A basic app fits the Workers Free plan: the bundled Worker is about 1.3 MB compressed, under the Free plan’s 3 MB limit.
Prerequisites: a Cloudflare account. Wrangler is installed as a dev dependency of the project.
Project Structure
Section titled “Project Structure”Directoryroot
Directorysrc/
- index.ts
Directorypublic/
- logo.png
Directoryassets/ copied from the package at build time, not committed
- …
- package.json
- tsconfig.json
- wrangler.jsonc
- .dev.vars local secrets, not committed
- .gitignore
Configuration Files
Section titled “Configuration Files”import { createCloudflareWorker } from '@crowdin/app-project-module/cloudflare';
export default createCloudflareWorker( (env) => ({ name: 'My App', identifier: 'my-app', description: 'Crowdin app deployed on Cloudflare Workers', clientId: env.CROWDIN_CLIENT_ID, clientSecret: env.CROWDIN_CLIENT_SECRET, projectTools: [ { // The SDK renders this form and stores what users submit in D1, per project formSchema: { title: 'Project settings', type: 'object', required: ['contactEmail'], properties: { contactEmail: { type: 'string', format: 'email', title: 'Contact email' }, notifyOnComplete: { type: 'boolean', title: 'Notify me when translations are complete', default: true }, notes: { type: 'string', title: 'Notes for translators' }, }, }, formUiSchema: { notes: { 'ui:widget': 'textarea' }, 'ui:submitButtonOptions': { submitText: 'Save' }, }, }, ], }), { setup: ({ app }) => { app.get('/api/health', (_req, res) => res.json({ ok: true })); }, },);{ "$schema": "node_modules/wrangler/config-schema.json", "name": "crowdin-app", "main": "src/index.ts", "compatibility_date": "2025-09-01", "compatibility_flags": [ "nodejs_compat", "enable_nodejs_http_modules", "enable_nodejs_http_server_modules" ], // Copies the SDK's UI files (styles, form and login pages) into public/assets "build": { "command": "npx crowdin-apps-sdk prepare-assets" }, "assets": { "directory": "public", "binding": "ASSETS", // Module pages go through the Worker, which checks the Crowdin JWT; the SDK's UI files are served directly "run_worker_first": ["/*", "!/assets/*"] }, "d1_databases": [ { "binding": "DB", "database_name": "crowdin-app-db", "database_id": "your-d1-database-id" } ], // One trigger runs every task the app schedules "triggers": { "crons": ["* * * * *"] }, "observability": { "enabled": true } // CROWDIN_CLIENT_ID and CROWDIN_CLIENT_SECRET are secrets: `npx wrangler secret put <NAME>`}{ "name": "crowdin-app", "private": true, "type": "module", "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy", "cf-typegen": "wrangler types --include-runtime false --env-interface CloudflareEnv" }, "dependencies": { "@crowdin/app-project-module": "^3.15.0" }, "devDependencies": { "@cloudflare/workers-types": "^5.20261103.1", "@types/express": "^5.0.6", "typescript": "^5.9.3", "wrangler": "^4.157.0" }}{ "compilerOptions": { "target": "es2022", "module": "es2022", "moduleResolution": "bundler", "lib": ["es2022"], "types": ["@cloudflare/workers-types"], "strict": true, "skipLibCheck": true, "noEmit": true }, "include": ["src"]}CROWDIN_CLIENT_ID=your-client-idCROWDIN_CLIENT_SECRET=your-client-secretnode_modules.wrangler.dev.varspublic/assetsdistKeep @cloudflare/workers-types within the range your Wrangler version declares as a peer dependency (npm view wrangler@<version> peerDependencies). A mismatched pair makes npm install fail with ERESOLVE.
Deploy
Section titled “Deploy”- Install dependencies:
npm install - Create the D1 database, and copy the printed ID into
wrangler.jsonc:If Wrangler offers to add the binding to your config, decline: the block already exists, and an added entry gets a different binding name thanTerminal window npx wrangler d1 create crowdin-app-dbDB. - Set the Crowdin OAuth app credentials as secrets:
Terminal window npx wrangler secret put CROWDIN_CLIENT_IDnpx wrangler secret put CROWDIN_CLIENT_SECRET - Deploy:
npm run deploy. Wrangler runs thebuildcommand first, which copies the SDK’s UI files intopublic/assets. - Install the app in Crowdin from
https://<worker-name>.<your-subdomain>.workers.dev/manifest.json, or from your custom domain.
For local development, npm run dev serves the app at http://localhost:8787 with values from .dev.vars.
How the Adapter Configures the App
Section titled “How the Adapter Configures the App”createCloudflareWorker(configure, options) calls configure(env) once per isolate, on the first request or cron tick, and fills in what the Worker provides. Anything you set in the returned config is kept as is.
- Storage. The
DBbinding becomesd1Config. - Files. The
FILESbinding, an R2 bucket, becomes thefileStore. Only apps with a file processing or custom MT module need it; see File Storage. - UI files. The
ASSETSbinding becomesassetsConfig, withassetsPathset to/assets. If those files are missing, the Worker logs once thatprepare-assetshas to run. - Cron. A built-in scheduler runs every task the app and the module schedule from the single
* * * * *trigger. See Background jobs on Workers. If you set your owncron, the adapter does not drive it and logs a warning; see below. - Webhooks. Each webhook gets
deferResponse: trueunless it sets the option, so the Worker keeps running until the callback completes. - Base URL. It is detected from the first request, including the
httpsprotocol. Set it yourself, as theBASE_URLvariable orbaseUrl, when the app has a custom domain and theworkers.devURL is also reachable, or when scheduled tasks build links to the app: a cron tick can start an isolate before any request has arrived.
To read bindings under other names, pass bindings:
export default createCloudflareWorker(configure, { bindings: { db: 'Database', files: 'Bucket', assets: 'Static' },});To drive your own cron, override scheduled on the exported handler. Call the adapter’s handler too: it runs the D1 migration and, for integrations, the recovery of interrupted jobs.
const worker = createCloudflareWorker(configure);
export default { ...worker, async scheduled(controller, env, ctx) { await worker.scheduled!(controller, env, ctx); await myCron.run(controller.scheduledTime); // your scheduler },};The options also take two hooks. beforeEndpoints({ app, env }) runs before the module registers its routes, for middleware that should wrap them or routes that must take precedence. setup({ app, crowdinApp, env }) runs once the module’s routes exist, for your own routes and for anything that needs crowdinApp.
File Storage
Section titled “File Storage”File processing modules (custom file format, pre-import, post-import, pre-export, post-export, translations alignment) and custom MT modules store a result larger than 4.9 MB as a temporary file. An app with one of these modules needs an R2 bucket; other apps do not.
Create the bucket with npx wrangler r2 bucket create crowdin-app-files and bind it as FILES:
"r2_buckets": [ { "binding": "FILES", "bucket_name": "crowdin-app-files" }]The app does not delete these files. Add a lifecycle rule to the bucket, so that R2 removes each file one day after its creation:
npx wrangler r2 bucket lifecycle add crowdin-app-files expire-files --expire-days 1 --forceEnvironment Types
Section titled “Environment Types”npm run cf-typegen generates an Env interface from wrangler.jsonc and .dev.vars without starting workerd, because --include-runtime false leaves the runtime types to @cloudflare/workers-types. Pass it to the adapter to type your own bindings:
export default createCloudflareWorker<CloudflareEnv>((env) => ({ // env is typed from wrangler.jsonc}));With @cloudflare/vite-plugin, copy the UI files in the build script instead of build.command. Vite copies public/ into the client output, so no extra Vite plugin is needed:
{ "scripts": { "build": "crowdin-apps-sdk prepare-assets && vite build" }}During vite dev, keep Vite’s own paths away from the Worker by extending run_worker_first to ["/*", "!/assets/*", "!/@*", "!/src/*", "!/node_modules/*"].
observability.enabled sends the Worker’s logs to Workers Logs in the dashboard. To stream them while you test, for example during an install, run npx wrangler tail.
Integrations
Section titled “Integrations”Project integrations run on Workers, with these limits:
- Plan. A real sync exceeds the Free plan’s limits of 10 ms CPU and 50 external subrequests per invocation. Use the Workers Paid plan for integrations.
- Sync length. A sync started from the integration UI runs after its
202response, and Workers may stop that work about 30 seconds later. A sync that is cut off stays unfinished until the next cron tick at least 15 minutes after its last progress update. That tick marks it failed, with “Interrupted: the job did not finish within the Workers time limit”, so new syncs of the integration are accepted again. Nothing is replayed. - Base URL. Set
BASE_URL, because integration cron tasks can run in an isolate that has not served a request yet. - API docs. The
/api-docspage lists only the endpoints fromapi.docFile, because the descriptions of the default integration endpoints are read from the package’s files on disk. - Webhook queue.
webhooks.queueUrl(AMQP) is not supported. Integration webhooks work through the regular HTTP endpoints.
Manual Wiring
Section titled “Manual Wiring”The adapter covers the setup above. If you need full control, for example to share the Worker with other code, wire the module yourself in an ESM entry file: create the app with express() from @crowdin/app-project-module, pass d1Config, fileStore, assetsConfig, assetsPath and a custom cron to addCrowdinEndpoints, call app.listen(3000), and answer fetch with httpServerHandler({ port: 3000 }) from cloudflare:node. In that setup:
- Provide a custom
cron, even if the app schedules nothing. Without one, the module falls back to node-cron, which does not run on Workers, and every request returns HTTP 500. - Call your scheduler from the Worker’s
scheduledhandler, and await the tasks so they get the Cron Trigger time limit. - Set
baseUrl: request detection seeshttpbehindhttpServerHandler. - Set
deferResponse: trueon webhooks. - Mark jobs that the Workers time limit interrupted as failed, or they block new syncs of their integration.