This is an advanced guide. You don't need this - skip the hassle and deploy to Contember Cloud. To complete this guide, you are expected to know how to run Docker containers in production.
What you'll need
- PostgreSQL database (e.g. AWS RDS for PostgreSQL, DigitalOcean Managed Database)
- S3-compatible storage for file uploads (optional if you don't need file upload capability; e.g. AWS S3, DigitalOcean Spaces, SeaweedFS, CEPH, Zenko CloudServer)
- SMTP server to send emails from (optional)
- Some way to run Docker image.
Deploy Contember Engine
1. Setup PostgresSQL database
Required PostgresSQL version is 10 or newer.
Contember needs the following databases:
- tenant (for information about users and their roles) and
- content database for each deployed project.
Contember itself manages these databases and runs migrations upon startup. You don't need to create content database if the PostgresSQL user given to Contember has permission to create it on its own.
2. Setup S3 bucket
This bucket will be used to store user uploads. You will need a key and secret with permissions to create objects.
3. Setup SMTP server
Get SMTP credentials for your mailing server - used for example for password reset.
4. Run Contember Engine Docker container
Contember Engine is deployed as a Docker container. It's published on Docker Hub as contember/engine.
Configure the following environment variables:
NODE_ENV: "production"
CONTEMBER_PORT: "4000" # Port on which the service will be available
CONTEMBER_LOGIN_TOKEN: "..." # Login token used (20 random [0-9a-f] characters; can be generated by `openssl rand -hex 20` command)
CONTEMBER_ROOT_TOKEN: "..." # Root token used (20 random [0-9a-f] characters; can be generated by `openssl rand -hex 20` command)
CONTEMBER_ROOT_EMAIL: "[email protected]" # Superadmin user's e-mail
CONTEMBER_ROOT_PASSWORD: "my-secret-password" # Superadmin user's password
# Credentials for database:
DEFAULT_DB_HOST: "your-db-server.com"
DEFAULT_DB_PORT: "5432"
DEFAULT_DB_USER: "postgres"
DEFAULT_DB_PASSWORD: "postgres"
DEFAULT_DB_SSL: "true"
TENANT_DB_NAME: "tenant" # Name of tenant database - see first step
DEFAULT_DB_NAME: "my-project" # Optional - name of database to
# or BLOG_DB_NAME: "blog" where blog is slug of the project.
# S3 credentials - see step 2
DEFAULT_S3_REGION: "rgn1"
DEFAULT_S3_KEY: "..."
DEFAULT_S3_SECRET: "..."
DEFAULT_S3_ENDPOINT: "https://rgn1.yours3provider.com"
DEFAULT_S3_BUCKET: "your-bucket"
# SMTP credentials - see step 3
TENANT_MAILER_HOST: "send.your-smtp.com"
TENANT_MAILER_PORT: "2525"
TENANT_MAILER_FROM: "[email protected]"
HTTP server should start on the specified port. You can add a load-balancer in front of the app. The container is stateless and therefore horizontally scalable.
Graceful shutdown
On SIGTERM, the engine stops taking requests and exits as soon as the ones in flight have finished — usually within milliseconds. A load balancer or an orchestrator typically stops routing to a container only some time after it sends that signal, so requests still on their way are refused. Set CONTEMBER_SHUTDOWN_DELAY_MS to keep serving normally for that long after SIGTERM before shutting down:
CONTEMBER_SHUTDOWN_DELAY_MS: "5000"
This is the in-process equivalent of a Kubernetes preStop sleep hook; a few seconds covers endpoint propagation there. Keep it well below terminationGracePeriodSeconds, which counts the delay too. SIGINT (Ctrl+C) never waits.
Lock waits during migrations
A schema migration often needs an ACCESS EXCLUSIVE lock on a table. While it waits for a long-running query to release the table, PostgreSQL queues every later query on that table behind it, so the application stops responding even before the migration starts its work. Set CONTEMBER_SYSTEM_API_MIGRATION_LOCK_TIMEOUT_MS to let a migration give up waiting after that long, roll back, and try again:
CONTEMBER_SYSTEM_API_MIGRATION_LOCK_TIMEOUT_MS: "2000"
CONTEMBER_SYSTEM_API_MIGRATION_MAX_ATTEMPTS: "5" # default 5
CONTEMBER_SYSTEM_API_MIGRATION_RETRY_DELAY_MS: "1000" # default 1000
When an SQL statement of a schema migration hits the lock timeout, a deadlock, or a serialization failure (SQLSTATE 40001: the application changed a row the migration updates after the migration started), the whole migration transaction rolls back and runs again. Without the timeout, none of these are retried. When the attempts run out, the migration fails as before and nothing is applied. A content migration that hits the timeout is not retried: it fails at once. The timeout applies only to the migration transaction and overrides the database lockTimeoutMs there. Applying a snapshot to an empty project is not affected. Unset, a migration waits for its locks as long as PostgreSQL lets it.
All values must be positive integers (the retry delay may be 0), because PostgreSQL reads a lock_timeout of 0 as no limit. The attempts and the delay require the lock timeout, because without it nothing is retried. The engine refuses to start otherwise.
To add a sequence to an existing column without rewriting the table, see Adding a sequence without rewriting the table.
After the server is running, use Contember CLI to set up other API tokens and invite users. Deploy the
blog project to this instance with CONTEMBER_API_URL and CONTEMBER_API_TOKEN set:
contember deploy blog --yes
The API token can be the superadmin token. You can also create a project-scoped deployment token:
contember tenant api-key create --project blog --description "Blog deployment" --role deployer
The command prints the new token once. Store it and use it as CONTEMBER_API_TOKEN for later deploys.
Deploy Contember Interface
Contember Interface can be deployed as single page application. It's bundled using Vite.
You will need to modify admin/index.tsx and admin/vite.config.ts files based on following examples:
import * as React from 'react'
import { ApplicationEntrypoint, LoginEntrypoint, Pages, runReactApp } from '@contember/admin'
import { Layout } from './components/Layout'
import '@contember/admin/index.css'
const apiBaseUrl = import.meta.env.VITE_CONTEMBER_ADMIN_API_BASE_URL as string
const loginToken = import.meta.env.VITE_CONTEMBER_ADMIN_LOGIN_TOKEN as string
const projectSlug = import.meta.env.VITE_CONTEMBER_ADMIN_PROJECT_NAME as string
if (window.location.pathname === '/') {
// Login page
runReactApp(
<LoginEntrypoint
apiBaseUrl={apiBaseUrl}
loginToken={loginToken}
projects={[projectSlug]}
formatProjectUrl={it => `/${it.slug}/`}
/>,
)
} else if (window.location.pathname.startsWith('/' + projectSlug)) {
// Project interface itself page
runReactApp(
<ApplicationEntrypoint
basePath={`/${projectSlug}/`}
apiBaseUrl={apiBaseUrl}
project={projectSlug}
stage="live"
children={<Pages layout={Layout} children={import.meta.glob('./pages/**/*.tsx')} />}
/>
)
} else {
// Page not found - redirect to login
window.location.href = '/'
}
import { defineConfig } from 'vite'
export default defineConfig(({ command }) => ({
base: '/',
}))
Furthermore your .env files (.env.development and .env.production) should contain referenced variables:
# URL where Engine is running
VITE_CONTEMBER_ADMIN_API_BASE_URL=http://localhost:1481
# Login token needed to perform login - configured in env variable of Engine
VITE_CONTEMBER_ADMIN_LOGIN_TOKEN=1111111111111111111111111111111111111111
# Your project's slug (used to connect to correct GraphQL endpoint)
VITE_CONTEMBER_ADMIN_PROJECT_NAME=headless-cms
After these changes you can run npm run build-admin command. It will create bundle in admin/dist folder, which you can deploy to any hosting, which needs to serve the index.html file for any request that doesn't match assets.