Yes, if your code changed. The build step compiles frontend assets including any Vue components and CSS. If you deploy new code without rebuilding, your users will get stale assets. Setting APOS_RELEASE_ID to a git SHA ensures the cache is busted on each deploy, but you still need to run the build to produce new assets.
Deployment in ApostropheCMS
Configure ApostropheCMS for production: environment variables, database setup (MongoDB/PostgreSQL), S3 file storage, migrations, and a GitHub Actions CI/CD pipeline.
-
- Intermediate
- 17 min read
- Deployment and DevOps
- Last Updated August 5, 2026
What you'll learn
-
What a production ApostropheCMS deployment involves and how the moving parts fit together
-
Which environment variables ApostropheCMS requires and how to structure them across environments
-
How the build process works for standalone Apostrophe and for Apostrophe + Astro, and what order things need to happen in
-
How to configure a production database and file storage
-
How to handle database migrations safely across deployments
-
How to structure a repeatable CI/CD pipeline, with a reference GitHub Actions configuration
Do I have to host ApostropheCMS myself?
No. If you'd rather not manage your own infrastructure, ApostropheCMS offers managed hosting. See our Hosting Pricing. This guide is intended for those who prefer or require self-hosting.
What does a production ApostropheCMS deployment involve?
A production ApostropheCMS deployment has five essential, and one optional, moving parts. Understanding how they relate to each other before you configure anything will save you debugging time later.
The Node.js process. ApostropheCMS runs as a Node.js server. In production, that process needs to be managed by something that will restart it on crash and survive server reboots. docker and pm2 are the most common choices for self-hosted deployments.
The database. ApostropheCMS requires a persistent database for all content, users, sessions, and configuration. MongoDB and PostgreSQL are both production-supported options. SQLite is acceptable in production only if you are sure your needs will never scale beyond a single server.
File storage. Uploaded media — images, documents, and other attachments — needs to live somewhere persistent. On a single server you can use local disk, but for anything beyond a simple single-server deployment, S3-compatible object storage is the standard production approach. It's required for multi-server setups and makes backups and CDN delivery straightforward. Azure Blog Storage, Google Cloud Storage, and custom storage adapters are also supported.
Environment configuration. ApostropheCMS relies on environment variables for secrets, database connection strings, and runtime behavior. These should never be committed to source control.
The reverse proxy. Most production deployments place nginx, Caddy, or a cloud load balancer in front of ApostropheCMS. The reverse proxy handles HTTPS termination, compression, caching, and request routing while forwarding application traffic to the Node.js process.
The frontend build (Apostrophe + Astro projects only). If your project uses Apostrophe + Astro, the Astro frontend is a separate build that needs to run after the backend is ready. The two sub-projects have different environment variable requirements and different start commands.
What environment variables does ApostropheCMS require?
ApostropheCMS uses environment variables for secrets, database connections, and runtime configuration. None of these should be hardcoded or committed to source control.
Core variables
Variable | Description |
|---|---|
| Set to |
| A unique string identifying this deployment — used to bust asset caches when you deploy new code. A short git SHA works well: |
| Full connection string for your database. See database setup for format details. |
File storage variables
| Variable | Required | Description |
|---|---|---|
APOS_S3_BUCKET | For S3 storage | The name of your S3-compatible bucket. |
APOS_S3_SECRET | For S3 storage | AWS secret access key or equivalent. |
APOS_S3_KEY | For S3 storage | AWS access key ID or equivalent. |
APOS_S3_REGION | For S3 storage | Bucket region (e.g., us-east-1). |
APOS_S3_ENDPOINT | For non-AWS S3 | Required when using S3-compatible providers like DigitalOcean Spaces, Cloudflare R2, or MinIO. Not needed for AWS S3 itself. |
See file storage configuration for full setup details.
Apostrophe + Astro additional variables
If your project uses the Apostrophe + Astro integration, the following variables are required in addition to the core set above.
Backend (ApostropheCMS) — add to existing env:
| Variable | Description |
|---|---|
APOS_EXTERNAL_FRONT_KEY | A shared secret that the Astro frontend uses to authenticate requests to the ApostropheCMS backend. Must match the value set on the Astro side. |
Frontend (Astro) — separate env:
| Variable | Description |
|---|---|
APOS_EXTERNAL_FRONT_KEY | Must match the value set on the backend. |
APOS_HOST | The URL where the ApostropheCMS backend is reachable from the Astro process (e.g., http://localhost:3000 or an internal service URL). |
Structuring environment variables across environments
A reliable pattern for multi-environment projects: keep a .env.example file in source control with all required variable names but no values, and manage actual values in your hosting platform's secrets manager or CI/CD environment variable store.
.env.example
# .env.example — commit this NODE_ENV= APOS_RELEASE_ID= SESSION_SECRET= APOS_DB_URI= APOS_S3_BUCKET= APOS_S3_KEY= APOS_S3_SECRET= APOS_S3_REGION= # APOS_S3_ENDPOINT= # Uncomment for non-AWS S3 providers
For local development, copy .env.example to .env and fill in local values. Add .env to .gitignore. For staging and production, inject variables through your CI/CD platform or process manager.
Common environment variable mistakes
NODE_ENV not set to production. This is the single most common deployment mistake. Without it, ApostropheCMS serves unminified assets, skips several production optimizations, and may expose verbose error output. Always verify this is set correctly on your production server.
Reusing the same SESSION_SECRET across environments. Use a distinct secret per environment. If your staging secret leaks, you don't want it to also be valid on production.
Missing or mismatched APOS_EXTERNAL_FRONT_KEY. In Apostrophe + Astro projects, this value must be identical on both sides. A mismatch produces authentication errors that can be hard to trace back to their source.
Setting APOS_RELEASE_ID to a static string. If APOS_RELEASE_ID doesn't change between deployments, your users will be served stale cached assets after you deploy. Derive it from your git SHA or your CI/CD build number.
How does the build process work?
Standalone Apostrophe
ApostropheCMS includes a built-in asset pipeline. Running npm run build compiles and bundles frontend assets (JavaScript, CSS, and any Vue components in your project). You need to run this once before starting the server in production — or as part of your deployment pipeline.
The correct build and start sequence for standalone Apostrophe:
# Install production dependencies npm ci # Build frontend assets npm run build # Start the server node app.js
In production, replace node app.js with your process manager command (typically pm2 start ecosystem.config.js — see the pipeline section for a reference config).
Apostrophe + Astro
The Apostrophe + Astro integration runs two separate Node.js projects: the ApostropheCMS backend (typically on port 3000) and the Astro frontend (typically on port 4321). The Astro frontend communicates with the backend at the APOS_HOST address, so the backend must be running before the Astro build runs — Astro fetches content from the backend at build time.
The build sequence is the same regardless of how you deploy:
# 1. Build and start the ApostropheCMS backend first cd backend npm ci npm run build # Backend must be running before the Astro build starts pm2 start ecosystem.config.cjs --env production # 2. Once the backend is up, build the Astro frontend cd ../frontend npm ci npm run build # 3. Start the Astro frontend pm2 start ecosystem.config.cjs --env production # see pipeline section for reference configs
The difference between deployment options is where these two processes run, not how they build.
Recommended: single container with pm2-runtime
Both processes run inside one container, each on its own port, managed by pm2-runtime (see Docker instructions here) the variant of PM2 built for use as a Dockerfile CMD. (Regular pm2 start will not work here: it daemonizes and returns control to the shell, which causes the container to exit immediately. pm2-runtime stays in the foreground instead.) The container exposes only Astro, on port 4321, and the reverse proxy forwards traffic there. All communication between Astro and Apostrophe happens within the container.
This is our recommended default for new Apostrophe + Astro projects, not just one of two equally-good options. The reasoning:
- It scales more easily than a multi-container setup — you run and load-balance instances of a single container, rather than coordinating two separate services.
- You aren't forced to pick an orchestrator above the container level (e.g., docker-compose) just to keep two processes talking to each other.
- Astro and ApostropheCMS become an internal implementation detail. From the outside, an ops person is running one container.
For a draft PM2 configuration for this option, see the single-container ecosystem.config.cjs example in the pipeline section below.
Alternative: multiple containers
If you need to scale or update the frontend and backend independently, running them as separate containers on an internal Docker network is possible — the Astro container reaches ApostropheCMS over the internal network, set as APOS_HOST in its environment. We're not covering a full reference setup for this here, since the single-container pm2-runtime approach above is our recommended path. If you go this route instead, know that container-orchestration tools like docker-compose don't guarantee one container is actually ready before another starts (only that it's started), so you'll need a real health check between the two services rather than relying on something like depends_on alone.
How do I set up a production database?
ApostropheCMS supports MongoDB and PostgreSQL for production deployments. SQLite is recommended for local development only.
Choosing a database
For a full comparison of the two options — including tradeoffs around operational complexity, hosting options, and migration paths — see the Choosing a database guide. The short version:
- MongoDB is the longer-established option with mature ApostropheCMS support. Managed options include MongoDB Atlas.
- PostgreSQL is fully production-supported as of ApostropheCMS 4.31.0. Managed options include Amazon RDS, Supabase, Neon, and most major cloud providers.
Configuring the database connection
Set APOS_DB_URI to your full connection string.
MongoDB
APOS_DB_URI=mongodb+srv://username:password@cluster.mongodb.net/mydb?retryWrites=true&w=majority
PostgreSQL
APOS_DB_URI=postgresql://username:password@host:5432/mydb
ApostropheCMS detects the database type from the connection string prefix (mongodb://, mongodb+srv://, or postgresql://) and loads the appropriate driver automatically. No additional configuration is required.
For PostgreSQL, ApostropheCMS also supports the standard PG* environment variables (PGUSER, PGPASSWORD, PGHOST, PGPORT) as an alternative to a full connection string. This can be useful if your infrastructure already has these variables set. APOS_DB_URI is the recommended approach for new projects — it's explicit, portable, and works the same way for both MongoDB and PostgreSQL.
Production database checklist
- Enable authentication — do not run a database without a password in production.
- Restrict network access to your application server's IP or private network.
- Enable automated backups. Before any significant deployment, take a manual snapshot.
- Before upgrading ApostropheCMS itself, take a database snapshot and verify that your file storage backups are current. This provides a rollback path if an upgrade exposes unexpected issues.
- Use a dedicated database user with only the permissions your app needs (read/write on the app database; no admin privileges).
- For MongoDB: enable TLS. For PostgreSQL: use
sslmode=requirein your connection string.
How do I configure file storage for production?
By default, ApostropheCMS stores uploaded files on the local filesystem in the public/uploads directory. This works for local development but has two significant problems in production: files are lost when a container or server is replaced, and it doesn't work at all in multi-server deployments.
The standard production approach is S3-compatible object storage. ApostropheCMS includes the @apostrophecms/uploadfs module, which handles the integration.
Configuring S3 storage
Set the S3 environment variables listed in the environment variables section, then configure the uploadfs module in your project's app.js:
app.js
// app.js import apostrophe from 'apostrophe'; apostrophe({ root: import.meta, shortName: 'my-project', modules: { '@apostrophecms/uploadfs': { options: { storage: 's3', // Credentials and bucket come from environment variables automatically // when using the standard APOS_S3_* variable names } } } });
For non-AWS S3-compatible providers, set APOS_S3_ENDPOINT to your provider's endpoint URL (note: for non-AWS S3 services, you should not set both the endpoint and the APOS_S3_REGION, just the endpoint):
# DigitalOcean Spaces example APOS_S3_ENDPOINT=https://nyc3.digitaloceanspaces.com APOS_S3_BUCKET=my-bucket
CDN configuration
If you're serving uploads through a CDN, set the uploadfs cdn option to your CDN base URL. ApostropheCMS will prefix all attachment URLs with it automatically:
'@apostrophecms/uploadfs': { options: { storage: 's3', cdn: { enabled: true, url: 'https://cdn.example.com' } } }
Migrating existing local uploads to S3
If you've been developing locally with file uploads accumulating in public/uploads, you'll need to move those files to your S3 bucket before switching to S3 storage in production. This is a one-time migration, not something that runs in your deployment pipeline.
The AWS CLI sync command is the straightforward way to do this. Note that the commands below sync straight to the bucket root, not to an /uploads subfolder — the best way to think about it is that the S3 bucket itself is your uploads directory, not a container that holds one:
# Dry run first — preview what will be transferred without actually doing it aws s3 sync ./public/uploads s3://your-bucket --dryrun # Once you've verified the output, run without --dryrun to execute aws s3 sync ./public/uploads s3://your-bucket
For non-AWS S3-compatible providers, add --endpoint-url:
aws s3 sync ./public/uploads s3://your-bucket/uploads \ --endpoint-url https://nyc3.digitaloceanspaces.com
How do I handle database migrations across deployments?
ApostropheCMS handles most schema changes automatically at startup. When you add or reconfigure a field, ApostropheCMS updates the database structure the next time the application starts — you don't write migration files for routine field changes.
What's automatic:
- Adding new fields (new content gets the field; existing documents get the default value)
- Many schema changes, including some field type changes
- Adding or removing indexes that ApostropheCMS manages
Field type changes should always be tested against existing content before deployment. While many changes are handled automatically, some conversions may require custom migration logic depending on how existing data is stored.
What requires manual attention:
- Data transformations — if you're changing how existing content is stored (e.g., splitting one field into two, or transforming stored values), you'll need to write and run a migration script.
- Removing fields — ApostropheCMS won't delete data from fields you've removed from the schema. The data stays in the database; it just won't be exposed to the application. This is generally safe but will accumulate unused data over time.
- Renaming fields — there is no automatic rename. Adding a new field and removing the old one leaves the old data in place but does not move it. If you need to rename a field and preserve its data, write a migration script.
Writing a migration script
For data transformations, ApostropheCMS provides a migration API through apos.migration.add(). Register migrations in a module's init function and ApostropheCMS tracks which migrations have run in the database and won't repeat them on subsequent deploys or restarts, even if the migration code remains in your project.
For a field rename specifically, avoid renaming in place. An in-place rename $rename from the old field name directly to the new one) requires the old process to be fully stopped first — running it while an old instance is still reading the old field name is risky and can throw errors, which isn't ideal for uptime. Instead, use $set and copy the data to the new field and leave the old field in place:
custom-module/index.js
1 export default { 2 init(self) { 3 self.apos.migration.add('my-field-rename', async () => { 4 const db = self.apos.db; 5 await db.collection('aposDocs').updateMany( 6 { oldField: { $exists: true } }, 7 { $rename: { oldField: 'newField' } } 8 ); 9 }); 10 } 11 };
This costs a bit of leftover data in oldField, but it means the old code path keeps working correctly while the migration runs across instances, and it leaves you the option to write a rollback migration if something goes wrong. Clean up the old field in a later release, once you're confident the new one is solid.
Deployment order for migrations
Migrations run automatically when ApostropheCMS starts. In a zero-downtime deployment with multiple app instances, run the new version on one instance first and let migrations complete before bringing up additional instances. This avoids race conditions where multiple instances attempt to run the same migration simultaneously.
How do I structure a repeatable deployment pipeline?
The goal of a deployment pipeline is to make every deployment identical: the same steps, in the same order, with no manual variation. The sections above cover what each step does — this section covers how to put them together.
The deployment sequence
For standalone Apostrophe, the steps in order are:
1. Check out the new code
2. Install dependencies npm ci)
3. Build assets npm run build)
4. Run database migrations npm run migrate)
5. Restart the application process
6. Verify the deployment (health check)
For Apostrophe + Astro, the provided npm scripts in the root package.json take care of completing each of steps 2-4 sequentially.
PM2 configuration reference
For self-hosted deployments, PM2 is the standard process manager.
Standalone Apostrophe:
ecosystem.config.cjs
1 module.exports = { 2 apps: [ 3 { 4 name: 'apostrophe', 5 script: 'app.js', 6 instances: 1, // Increase for multi-core; ensure session affinity if > 1 7 exec_mode: 'fork', // Use 'cluster' for multiple instances 8 env_production: { 9 NODE_ENV: 'production', 10 PORT: 3000 11 }, 12 // Graceful reload: wait for connections to close before restarting 13 kill_timeout: 5000, 14 wait_ready: true, 15 listen_timeout: 10000 16 } 17 ] 18 };
app.js
1 > import apostrophe from 'apostrophe'; 2 > 3 await apostrophe({ 4 root: import.meta, 5 shortName: 'my-project', 6 // ... your config 7 }); 8 9 process.send && process.send('ready');
Readiness checks and health checks solve different problems. Readiness checks tell process managers and dependent services when ApostropheCMS has finished starting. Health checks verify that the running application is responding correctly after startup. Production deployments often use both.
Apostrophe + Astro (single container, recommended approach):
Both processes run as separate PM2 apps in the same ecosystem.config.cjs, placed at the root of your project. In the container, this config is started with pm2-runtime rather than pm2 start (see the callout below).
ecosystem.config.js
1 module.exports = { 2 apps: [ 3 { 4 name: 'apostrophe', 5 script: 'app.js', 6 cwd: './backend', 7 env_production: { 8 NODE_ENV: 'production', 9 PORT: 3000 10 }, 11 kill_timeout: 5000, 12 wait_ready: true, 13 listen_timeout: 10000 14 }, 15 { 16 name: 'astro', 17 script: 'npm', 18 args: 'run start', 19 cwd: './frontend', 20 env_production: { 21 NODE_ENV: 'production', 22 PORT: 4321, 23 APOS_HOST: '<http://localhost:3000>' 24 }, 25 // Astro should only start after ApostropheCMS is ready 26 // PM2 doesn't enforce startup order — handle this in your deploy script 27 kill_timeout: 5000, 28 wait_ready: true, 29 listen_timeout: 10000 30 } 31 ] 32 };
bash
1 # First deploy 2 pm2 start ecosystem.config.cjs --env production 3 4 # Subsequent deploys (zero-downtime reload) 5 pm2 reload ecosystem.config.cjs --env production
GitHub Actions reference configuration
The following workflow covers a standalone Apostrophe deployment via SSH to a server running PM2. Adapt the environment variable names and SSH commands to match your infrastructure.
.github/workflows/deploy.yml
1 name: Deploy 2 3 on: 4 push: 5 branches: 6 - main 7 8 jobs: 9 deploy: 10 runs-on: ubuntu-latest 11 12 steps: 13 - name: Check out code 14 uses: actions/checkout@v4 15 16 - name: Set up Node.js 17 uses: actions/setup-node@v4 18 with: 19 node-version: '24' 20 cache: 'npm' 21 22 - name: Install dependencies 23 run: npm ci 24 25 - name: Build assets 26 run: npm run build 27 env: 28 NODE_ENV: production 29 # APOS_RELEASE_ID is set here so the build embeds the correct cache-bust string 30 APOS_RELEASE_ID: ${{ github.sha }} 31 32 - name: Deploy to server 33 uses: appleboy/ssh-action@v1 34 with: 35 host: ${{ secrets.DEPLOY_HOST }} 36 username: ${{ secrets.DEPLOY_USER }} 37 key: ${{ secrets.DEPLOY_KEY }} 38 script: | 39 cd /var/www/my-project 40 git pull origin main 41 npm ci 42 npm run build 43 pm2 reload ecosystem.config.cjs --env production 44 45 - name: Health check 46 run: | 47 sleep 10 48 curl --fail <https://example.com/api/v1/@apostrophecms/page> || exit 1
This example rebuilds on the target server for simplicity. More advanced deployment pipelines typically build once, produce a deployment artifact or container image, and deploy that artifact unchanged.
A: actions/checkout@v4, actions/setup-node@v4 (Node 24)
B: npm ci then npm run build, APOS_RELEASE_ID set to github.sha
C: SSH in, git pull origin main, npm ci, npm run build, pm2 reload ecosystem.config.cjs --env production
D: curl --fail .../api/v1/@apostrophecms/page
Apostrophe + Astro pipeline
The pipeline structure follows the same recommended/alternative split as the deployment architecture above.
Recommended: single container
Both services deploy together in one pipeline job. The deploy script handles startup order explicitly — backend first, health check, then frontend.
.github/workflows/deploy.yml
1 jobs: 2 deploy: 3 runs-on: ubuntu-latest 4 steps: 5 - uses: actions/checkout@v4 6 7 - name: Set up Node.js 8 uses: actions/setup-node@v4 9 with: 10 node-version: '24' 11 cache: 'npm' 12 13 - name: Build backend assets 14 run: npm ci && npm run build 15 working-directory: ./backend 16 env: 17 NODE_ENV: production 18 APOS_RELEASE_ID: ${{ github.sha }} 19 20 - name: Build Astro frontend 21 run: npm ci && npm run build 22 working-directory: ./frontend 23 env: 24 NODE_ENV: production 25 APOS_HOST: <http://localhost:3000> 26 APOS_EXTERNAL_FRONT_KEY: ${{ secrets.APOS_EXTERNAL_FRONT_KEY }} 27 28 - name: Deploy to server 29 uses: appleboy/ssh-action@v1 30 with: 31 host: ${{ secrets.DEPLOY_HOST }} 32 username: ${{ secrets.DEPLOY_USER }} 33 key: ${{ secrets.DEPLOY_KEY }} 34 script: | 35 cd /var/www/my-project 36 git pull origin main 37 # Reload backend first 38 cd backend && npm ci && npm run build 39 pm2 reload ecosystem.config.cjs --only apostrophe --env production 40 # Wait for backend health check before reloading frontend 41 sleep 10 && curl --fail <http://localhost:3000/api/v1/@apostrophecms/page> 42 # Then reload frontend 43 cd ../frontend && npm ci && npm run build 44 pm2 reload ecosystem.config.js --only astro --env production
A: actions/checkout@v4, actions/setup-node@v4 (Node 24)
B: working-directory ./backend, npm ci && npm run build, APOS_RELEASE_ID set to github.sha
C: working-directory ./frontend, npm ci && npm run build, APOS_HOST, APOS_EXTERNAL_FRONT_KEY
D: SSH in, git pull origin main, cd backend && npm ci && npm run build, pm2 reload ecosystem.config.cjs --only apostrophe --env production
E: sleep 10 && curl --fail http://localhost:3000/api/v1/@apostrophecms/page
F: cd ../frontend && npm ci && npm run build, pm2 reload ecosystem.config.cjs --only astro --env production
Alternative: multiple containers
If you've gone with separate containers instead, structure this as two jobs with an explicit dependency — a deploy-frontend job with needs: deploy-backend, where the backend job's steps end in a health check the frontend job can rely on before it starts. We're not laying out a full reference workflow for this here, since the single-container approach above is our recommended path.
Key Takeaways
-
A production ApostropheCMS deployment has six moving parts: the Node.js process, the reverse proxy, the database, file storage, environment configuration, and (for Apostrophe + Astro) the frontend build.
-
NODE_ENV=production,APOS_RELEASE_ID,SESSION_SECRET, andAPOS_DB_URIare required for every deployment. Missing any of them causes subtle, hard-to-trace failures. -
APOS_DB_URIis the current standard;APOS_MONGODB_URIis recognized but legacy. -
SQLite is recommended for local development only. Use MongoDB or PostgreSQL in production, unless you are sure your needs will not grow beyond a single server.
-
S3-compatible object storage is the standard production file storage approach — it's required for multi-server setups and strongly recommended for everything else.
-
ApostropheCMS handles most schema changes automatically at startup. Manual migration scripts are needed only for data transformations, field renames, or other operations that move or transform existing data.
-
Set
APOS_RELEASE_IDto a git SHA or build number on every deploy. A static value means stale assets after deployment.
Next steps
Once your deployment pipeline is running, the following resources cover platform-specific setup and the operational tooling you'll want in place for running this in production long-term.
Docker deployment cookbook — containerizing ApostropheCMS with Docker and Docker Compose
Ubuntu hosting cookbook — step-by-step server setup with nginx and PM2
Profiling with OpenTelemetry — trace request performance in production
Logging — configuring log levels and production logging with popular packages like pino and bunyan
Common Questions
Yes. Your database and file storage must be shared across all instances, and each instance must be running the same application version. ApostropheCMS uses a database-backed session store by default, which supports multi-instance deployments without requiring sticky sessions. Review your infrastructure requirements and test your deployment architecture before scaling horizontally.
npm run build compiles frontend assets. npm run release is a higher-level command some projects configure to run the full deployment sequence (build, migrate, restart). Check your package.json scripts — the exact commands available depend on your project setup. If npm run release exists, read what it does before relying on it in a pipeline.
ApostropheCMS tracks which migrations have run. If a deployment fails after migrations start, re-running the deployment will skip already-completed migrations and attempt the remaining ones. For custom migration scripts, make sure your migration logic is idempotent — safe to run more than once — to avoid leaving the database in an inconsistent state.
The most common cause is APOS_RELEASE_ID being set to a static value or not set at all. When this value doesn't change between deployments, browser caches and CDNs don't know to fetch new assets. Set it to your git SHA: APOS_RELEASE_ID=$(git rev-parse --short HEAD) in your pipeline.