Skip to content

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 can deploy your Crowdin app from existing repo (GitHub, Gitlab, Bitbucket).

Go to Vercel and proceed with next steps:

  • Create a new project.
  • Under the Environment Variables section, 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.

Project structure:

  • Directoryroot
    • index.js
    • logo.png
    • package.json
    • package-lock.json
    • vercel.json
    • .gitignore

App configuration:

index.js
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');
}
}
]
});
vercel.json
{
"version": 2,
"builds": [
{
"src": "./index.js",
"use": "@vercel/node"
}
],
"routes": [
{
"src": "/(.*)",
"dest": "/"
}
]
}
.gitignore
/node_modules/
package.json
{
"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"
}
}

You can use Serverless to deploy your Crowdin app to AWS Lambda.

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
handler.js
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);
serverless.yml
service: aws-node-express-api
frameworkVersion: '3'
provider:
name: aws
runtime: nodejs20.x
functions:
api:
handler: handler.handler
environment: ${file(env.json)}
url: true
env.json
{
"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>"
}
.gitignore
node_modules
.serverless
package.json
{
"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"
}

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.

  • 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
src/index.ts
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 }));
},
},
);
wrangler.jsonc
{
"$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>`
}
package.json
{
"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"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "es2022",
"module": "es2022",
"moduleResolution": "bundler",
"lib": ["es2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}
.dev.vars
CROWDIN_CLIENT_ID=your-client-id
CROWDIN_CLIENT_SECRET=your-client-secret
.gitignore
node_modules
.wrangler
.dev.vars
public/assets
dist

Keep @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.

  1. Install dependencies: npm install
  2. Create the D1 database, and copy the printed ID into wrangler.jsonc:
    Terminal window
    npx wrangler d1 create crowdin-app-db
    If Wrangler offers to add the binding to your config, decline: the block already exists, and an added entry gets a different binding name than DB.
  3. Set the Crowdin OAuth app credentials as secrets:
    Terminal window
    npx wrangler secret put CROWDIN_CLIENT_ID
    npx wrangler secret put CROWDIN_CLIENT_SECRET
  4. Deploy: npm run deploy. Wrangler runs the build command first, which copies the SDK’s UI files into public/assets.
  5. 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.

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 DB binding becomes d1Config.
  • Files. The FILES binding, an R2 bucket, becomes the fileStore. Only apps with a file processing or custom MT module need it; see File Storage.
  • UI files. The ASSETS binding becomes assetsConfig, with assetsPath set to /assets. If those files are missing, the Worker logs once that prepare-assets has 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 own cron, the adapter does not drive it and logs a warning; see below.
  • Webhooks. Each webhook gets deferResponse: true unless it sets the option, so the Worker keeps running until the callback completes.
  • Base URL. It is detected from the first request, including the https protocol. Set it yourself, as the BASE_URL variable or baseUrl, when the app has a custom domain and the workers.dev URL 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 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:

wrangler.jsonc
"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:

Terminal window
npx wrangler r2 bucket lifecycle add crowdin-app-files expire-files --expire-days 1 --force

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:

package.json
{
"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.

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 202 response, 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-docs page lists only the endpoints from api.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.

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 scheduled handler, and await the tasks so they get the Cron Trigger time limit.
  • Set baseUrl: request detection sees http behind httpServerHandler.
  • Set deferResponse: true on webhooks.
  • Mark jobs that the Workers time limit interrupted as failed, or they block new syncs of their integration.