Skip to content

Hosting ApostropheCMS + Astro in production ​

An ApostropheCMS + Astro project runs as two cooperating Node.js applications: the ApostropheCMS backend, which stores content and powers the editing UI, and the Astro frontend, which renders every page. Editors log in and edit on the Astro site itself, the same as visitors browse it. Astro passes the editing UI's assets and API calls through to ApostropheCMS behind the scenes. Everything in Hosting ApostropheCMS in production still applies to the backend.

This guide is the platform-independent reference for what changes when Astro sits in front of ApostropheCMS. It explains how the two applications fit together and walks through the decisions and configuration every production deployment needs, whatever host you choose: topology, output mode, database, reverse proxy, environment variables, process management, and a final checklist. It does not walk through any one platform step by step. For that, see Deploying ApostropheCMS + Astro projects, which compares platform options and includes a single-server deployment on DigitalOcean. The other Astro tutorials cover static builds and a full Railway and Vercel deployment.

INFO

ApostropheCMS hosting runs ApostropheCMS + Astro projects for you, including the servers and their operating system updates, the database, media storage, HTTPS certificates, and backups. With managed hosting, most of what this guide covers is handled for you. You still deploy your own codebase, so updating ApostropheCMS, Astro, and your other dependencies, including for security fixes, remains part of your project's work. See Deploying with ApostropheCMS Hosting.

How requests flow ​

Visitors and editors only ever talk to Astro. The @apostrophecms/apostrophe-astro integration fetches page data from ApostropheCMS for each request and proxies a set of reserved routes straight through to the backend:

  • /api/v1/... and /[locale]/api/v1/... for REST API calls
  • /login and /[locale]/login for the login page
  • /apos-frontend/... for the admin UI assets
  • /uploads/... for uploaded media
  • Literal content files declared by ApostropheCMS modules, such as robots.txt and sitemap.xml

Because of this, ApostropheCMS does not need to be reachable from the public internet. Only Astro does.

INFO

These proxy routes exist only when Astro runs with output: 'server'. A static build contains none of them, so the published site has no login page or admin UI. Editing happens against a separate server-rendered environment. See Choosing an output mode.

Choosing a topology ​

Both applications on one server ​

The simplest production setup runs both processes on the same machine. Astro listens on the public-facing port behind your reverse proxy, and ApostropheCMS listens on a private port that only Astro can reach.

  • Configure the server's firewall to allow only SSH, HTTP and HTTPS, for example with ufw or your hosting provider's firewall, so the backend port can't be reached from outside. By default ApostropheCMS listens on all interfaces. As an optional second layer, set ADDRESS=127.0.0.1 for the ApostropheCMS process so it only accepts local connections. Don't set it when ApostropheCMS runs in a container. There, 127.0.0.1 is the container's own loopback, so nothing outside the container can reach the backend, including Astro if it runs in a separate container.
  • Point Astro at the backend with APOS_HOST=http://127.0.0.1:3000.
  • Plan for at least 2 GB of RAM. A small site running two processes of each application uses about 825 MB at rest (two ApostropheCMS processes at 250–350 MB each and two Astro processes at about 120 MB each), which leaves room to rebuild in place. The ApostropheCMS asset build is the largest single demand, at about 750 MB, so on a 2 GB server add 1–2 GB of swap as a safety margin, or build in CI and copy the output to the server. Choose 4 GB for busier sites or more processes.

This is also the model ApostropheCMS hosting uses, so traffic between the two applications never leaves the machine.

Separate servers or services ​

You can host the backend and frontend on different platforms, for example ApostropheCMS on a VPS or container platform and Astro on a serverless host. This gives you independent scaling and lets you use platform-specific Astro adapters, at the cost of a network hop on every page request.

  • Place the two services in the same region, and on a private network if your platform supports it. Every uncached page view waits for a round trip to ApostropheCMS.
  • For single-site projects, add 'host' to the integration's excludeRequestHeaders option. Otherwise the browser's Host header, which names the Astro site, is forwarded to the ApostropheCMS server. When the backend is served over HTTPS, that mismatch makes every page fail with a 500 error that reports Hostname/IP does not match certificate's altnames.
  • Restrict direct public access to the backend where you can. Astro is the only client that needs it.
  • Check the frontend host's request size limit. Uploads from the admin UI pass through Astro, so on a serverless platform the limit on request size caps the files editors can upload, whatever storage the backend uses. On Netlify, for example, uploads over about 4 MB fail.

INFO

Multisite projects built with Assembly have additional hosting considerations, because each site is served from its own hostname. Contact us for advice on hosting a multisite project with an Astro frontend.

Choosing an output mode ​

The integration supports both Astro output modes:

ModeAstro settingNeeds a running backendUse it when
Server-side renderingoutput: 'server' with an SSR adapterAt request timeEditors need in-context editing on the live site, or content changes must appear immediately
Staticoutput: 'static'At build time onlyYou want the published site served as plain files, and can rebuild whenever content is published

Server-side rendering is the default in the starter kits and the right choice for most projects. Any Astro adapter that supports output: 'server' works. The starter kits ship with @astrojs/node in standalone mode. For serverless hosts, swap in that platform's adapter.

A static site still needs an SSR environment for editing. The usual pattern is an SSR pre-production site that editors work in, plus a static production build that is triggered when content is ready to publish. See Static builds with ApostropheCMS + Astro and Full static deployment with Railway and Vercel.

Choosing a database ​

Only ApostropheCMS connects to the database. Astro never does, so the choice doesn't change anything on the frontend. See Choosing a database for the tradeoffs. A few points affect how you host:

  • SQLite works well on a single server. Several ApostropheCMS processes on the same machine can share one SQLite file. Keep the file outside the directory you deploy into, on a persistent disk. SQLite can't be shared between servers, so plan to move to PostgreSQL or MongoDB before running the backend on more than one machine.
  • A managed database simplifies operations. Managed services such as MongoDB Atlas or a hosting provider's managed PostgreSQL run on their own servers, which frees memory on yours, and they usually include automatic backups. Place the database in the same region as the backend, because every page request waits on it.
  • Set the connection string with APOS_DB_URI. It accepts mongodb://, postgres://, and sqlite:// URIs. See Using SQLite and PostgreSQL for the formats.

Configuring Astro for your domain ​

Astro checks the origin of every POST, PUT, PATCH, and DELETE request to a server-rendered site that has a form-like body or no body at all, and rejects cross-site requests with 403 Cross-site POST form submissions are forbidden. To build the site's origin, Astro only trusts the Host and X-Forwarded-* headers when they match the security.allowedDomains option. With the default empty list, a production build treats every request as coming from http://localhost, so requests from your real domain fail the check.

The admin UI sends several requests of this kind through Astro's proxy, including logging out and uploading files. Editors can still log in and edit, but these actions fail. The development server builds the origin from the incoming request, so the problem typically appears only after you deploy a production build.

This applies to the Node adapter running behind a reverse proxy. Platform adapters can behave differently. On Netlify, for example, these requests succeed without the option.

List the public origin of each environment in astro.config.mjs:

mjs
export default defineConfig({
  output: 'server',
  security: {
    allowedDomains: [
      { protocol: 'https', hostname: 'www.example.com' }
    ]
  },
  // ...adapter, integrations, and other options
});
frontend/astro.config.mjs
  • Include every hostname the site answers on, such as the bare domain and www. If the same build also runs elsewhere, such as on a pre-production site for a static deployment or a staging site for testing, add those domains too.
  • Forward the original protocol and host from your reverse proxy. When NGINX terminates HTTPS, Astro receives plain HTTP. The Host and X-Forwarded-Proto headers in Configuring the reverse proxy let Astro see https://www.example.com, which matches the protocol in the pattern.
  • Include the port for non-standard ports, for example { protocol: 'http', hostname: 'localhost', port: '4321' } when testing a production build locally.
  • Apply the option only to server-rendered builds. With Astro 6 and later, it produces a warning for every prerendered page in a static build. See Upgrading apostrophe-astro for a pattern that applies it conditionally.

Configuring the reverse proxy ​

Point the reverse proxy at Astro, not at ApostropheCMS. Astro passes the requests that ApostropheCMS handles, including the admin UI, API calls and uploads, through to the backend. A minimal NGINX configuration for a site on one server:

nginx
server {
  listen 80;
  server_name www.example.com;

  # Allow media uploads larger than NGINX's 1 MB default
  client_max_body_size 50m;

  location / {
    proxy_pass http://127.0.0.1:4321;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
  }
}
/etc/nginx/conf.d/example.conf
  • Raise client_max_body_size. NGINX rejects request bodies over 1 MB by default, so editors can't upload larger images or files. The upload fails with 413 Request Entity Too Large. Set a limit that fits the largest file editors will upload.
  • Make sure the HTTPS server block has these settings. Certbot adds HTTPS to the existing block, so settings there carry over. If you write a separate block for port 443 yourself, repeat the client_max_body_size and proxy_set_header lines in it.
  • Keep Host and X-Forwarded-Proto. Astro uses them, together with security.allowedDomains, to determine the site's public origin.

Environment variables ​

VariableSet onRead atPurpose
APOS_EXTERNAL_FRONT_KEYBothRuntime (and build time for static builds)Shared secret that authorizes Astro to request page data. Must be identical on both sides.
APOS_HOSTAstroBuild timeBase URL of the ApostropheCMS backend, including the port if it isn't 80 or 443. Overrides the aposHost integration option.
APOS_PREFIXAstroBuild timeURL prefix, if the backend uses the prefix option. Overrides the aposPrefix integration option.
APOS_BASE_URLApostropheCMSRuntimeThe public URL of the site, which is the Astro URL, not the backend URL. Used when ApostropheCMS generates absolute URLs.
APOS_RELEASE_IDApostropheCMSBuild time and runtimeShort, unique string that identifies the release. Must have the same value for the asset build and the running site. Not needed if the deployment is a git checkout. See Deployment basics.
APOS_SESSION_SECRETApostropheCMSRuntimeSecret used to sign session cookies. See Set a session secret.
NODE_ENVApostropheCMSRuntimeSet to production. See Hosting ApostropheCMS in production.
APOS_DB_URIApostropheCMSRuntimeDatabase connection string. See Choosing a database.
PORTEach processRuntimeListening port. Both applications read this variable, so give each process its own value. Defaults to 3000 for the backend and 4321 for the frontend.

WARNING

The integration writes APOS_HOST and APOS_PREFIX into the build output when astro build runs. Setting them only when starting the server has no effect. If the backend URL changes, rebuild the frontend.

Managing the external front key ​

APOS_EXTERNAL_FRONT_KEY is what prevents other sites from pulling data not normally displayed on the page while posing as your frontend.

  • Generate a long random value and keep it in your platform's secret store, not in the repository.
  • Use a different value in each environment, such as staging and production.
  • To rotate it, update both applications and restart them together. While the values differ, ApostropheCMS answers Astro's page requests with 403 forbidden and logs an externalFrontKeyInvalid error.

You can set the backend value with the externalFrontKey option of @apostrophecms/express instead of the environment variable, but the environment variable takes precedence and keeps the secret out of code.

Deploying a release ​

Extend the deployment basics for ApostropheCMS with the Astro build. For a server-rendered site, deploy in this order:

  1. Install dependencies in both the backend and frontend directories.
  2. Build the backend: NODE_ENV=production node app @apostrophecms/asset:build. Unless the deployment is a git checkout, set APOS_RELEASE_ID for this step and give the running site the same value.
  3. Run migrations: NODE_ENV=production node app @apostrophecms/migration:migrate. Migrations also run when ApostropheCMS starts, but a separate step stops a failed migration from reaching the running site.
  4. Build the frontend: astro build, with APOS_HOST set to the production backend URL.
  5. Restart ApostropheCMS, then Astro. With PM2 in cluster mode, pm2 reload ecosystem.config.cjs does this with only a brief interruption. See Running the processes.

The starter kits include scripts for each step. From the project root, npm run build builds both halves and npm run migrate runs migrations. npm run serve-backend and npm run serve-frontend start them in production mode.

A static build is different: the backend must be running and populated before astro build starts, because Astro fetches every page and attachment during the build. If APOS_HOST or APOS_EXTERNAL_FRONT_KEY is missing, the integration silently skips copying attachments and literal content files.

Running the processes ​

Follow the advice in Run multiple processes for both applications, using a process manager such as PM2 to keep them running. On a single server, one PM2 ecosystem file can manage both:

cjs
module.exports = {
  apps: [
    {
      name: 'apostrophe',
      cwd: './backend',
      script: 'app.js',
      instances: 2,
      exec_mode: 'cluster',
      env: {
        NODE_ENV: 'production',
        ADDRESS: '127.0.0.1',
        PORT: 3000
      }
    },
    {
      name: 'astro',
      cwd: './frontend',
      script: 'dist/server/entry.mjs',
      // Load frontend/.env. Use an absolute path: in cluster mode a
      // relative path is not resolved against `cwd`
      node_args: `--env-file=${__dirname}/frontend/.env`,
      instances: 2,
      exec_mode: 'cluster',
      env: {
        NODE_ENV: 'production',
        HOST: '127.0.0.1',
        PORT: 4321
      }
    }
  ]
};
ecosystem.config.cjs

WARNING

Write this file in CommonJS, with module.exports, even if your project uses ES modules, as the starter kits do. PM2 loads its configuration file with require(), and the .cjs extension tells Node.js to treat the file as CommonJS whatever the "type" setting in package.json says. An ESM version fails before any process starts:

text
[PM2][ERROR] File ecosystem.config.cjs malformated
SyntaxError: Unexpected token 'export'

Because of this, the example above has no CJS/ESM toggle. It always displays as CommonJS whichever format you've selected elsewhere in the documentation.

Save the file in the project root, next to the backend and frontend directories. Each app's cwd is relative to that location. Point your reverse proxy at the Astro port. The Astro process binds to 127.0.0.1 here because the reverse proxy runs on the same machine. Use 0.0.0.0 on platforms that route traffic to the container from outside.

Keep secrets such as APOS_EXTERNAL_FRONT_KEY, APOS_SESSION_SECRET, and APOS_DB_URI out of this file and out of the repository:

  • Backend: put them in backend/.env. The starter kits' app.js loads that file with dotenv, for the running site and for command line tasks such as migrations.
  • Frontend: put APOS_EXTERNAL_FRONT_KEY in frontend/.env. The Astro server does not load .env files at runtime, so the node_args line above has Node.js load it. The path must be absolute. With a relative path, the Astro processes crash on startup with nothing in their logs, and PM2 marks them as errored.
  • Don't rely on exported shell variables. They only reach processes started from that shell, and they are lost when the server reboots.

pm2 env doesn't list variables loaded from these files, because Node.js and dotenv read them inside the process. To check that they loaded, request a page from Astro and look for key errors in pm2 logs.

To restart both applications after a reboot, run pm2 save and then pm2 startup, and follow the instructions it prints.

When both applications start at the same time, as they do after a reboot or a pm2 start, Astro is ready within a second or two, before ApostropheCMS has finished starting. Requests in that window fail, and the Astro logs show connect ECONNREFUSED 127.0.0.1:3000. These errors stop once ApostropheCMS logs that it is listening, and you can ignore them. For deployments, use pm2 reload ecosystem.config.cjs instead of pm2 restart. In cluster mode, reload replaces processes one at a time, which keeps the interruption short. Expect a few failed requests over a second or two, not zero downtime.

Media and caching ​

Uploaded images and files need storage that survives each deployment. There are two ways to provide it.

Cloud storage keeps uploads in Amazon S3 or another S3-compatible service, Azure Blob Storage, or Google Cloud Storage. Media storage then scales on its own, deployments never touch it, and the backend can run on more than one server or on a platform with no persistent disk. Media URLs point at the storage service directly, so media requests don't pass through Astro. To serve media through a CDN instead, configure the CDN to pull from your storage, and set the cdn option of @apostrophecms/uploadfs to the CDN's URL so that ApostropheCMS writes media URLs that point at it. See Using S3 storage for setup with several providers, and the @apostrophecms/uploadfs reference for every storage option.

Local disk works for a single server. By default, ApostropheCMS stores uploads in backend/public/uploads, and every media request travels through Astro's /uploads proxy to the backend. Keep the files in a directory outside the project, so that a fresh clone, a release-directory deployment, or git clean can't remove them, and link to it at the end of each deployment:

bash
# Run from the project root after each deployment, before restarting the processes
UPLOADS_DIR=/opt/apostrophecms/uploads
LINK=backend/public/uploads

mkdir -p "$UPLOADS_DIR"
if [ -d "$LINK" ] && [ ! -L "$LINK" ]; then
  echo "$LINK is a real directory. Move its contents to $UPLOADS_DIR and remove it first." >&2
  exit 1
fi
ln -sfn "$UPLOADS_DIR" "$LINK"

The user that runs ApostropheCMS must be able to write to the uploads directory. Include it in your backups along with the database.

Whichever storage you choose:

  • Pass through caching headers. Include 'cache-control' in the integration's includeResponseHeaders option so that the cache headers ApostropheCMS sets reach browsers and CDNs. See Caching for configuring them. The starter kits include this header by default.
  • Pass through security headers. If you use @apostrophecms/security-headers, list its headers in includeResponseHeaders too. The integration removes the nonce from the content-security-policy header's script-src value because Astro does not support it.

Running the site over time ​

Getting the site online is only the start. These practices apply to any production deployment:

  • Back up the database and the uploads. Content lives in two places: the database and the uploaded files, either on disk or in cloud storage. Back up both on a schedule, keep copies off the server, and test restoring them now and then. A managed database or your host's backup service can take care of part of this.
  • Make deployments repeatable. Script the release steps, or run them in CI, so every deployment installs dependencies, sets a new release ID, builds both applications, runs migrations, and reloads the processes in the same order. A missed step, such as a stale APOS_HOST or a mismatched release ID, is the most common cause of a broken deployment.
  • Configure outgoing email if you enable features that use it. The starter kits don't send email out of the box, but features such as password reset and form submission notifications from the @apostrophecms/form extension do. If you turn one on, configure an email transport in @apostrophecms/email before anyone relies on it. See Sending email.
  • Monitor the site. Use an uptime checker so you hear about outages before visitors do, and keep an eye on memory use and disk space. SQLite databases and uploads grow over time.
  • Keep the stack updated. Apply operating system security updates, stay on a supported Node.js release, and update ApostropheCMS, Astro, and the integration regularly. Test updates on a staging copy first.

Production checklist ​

  • Both applications run on Node.js. We recommend Node.js 24 LTS. Node.js 22.19 is the minimum version.
  • APOS_EXTERNAL_FRONT_KEY is a strong secret, identical for the frontend and backend applications, and different between development, staging and production environments.
  • APOS_HOST is set when astro build runs.
  • security.allowedDomains in astro.config.mjs lists the site's public origin, and the reverse proxy forwards Host and X-Forwarded-Proto. Test by logging out on the production site.
  • APOS_SESSION_SECRET is set to a long random value on the backend, not left at the built-in placeholder.
  • APOS_BASE_URL on the backend is the public Astro URL.
  • The backend has the same APOS_RELEASE_ID at build time and at runtime, unless the deployment is a git checkout.
  • The backend is not publicly reachable, or at least is not linked from anywhere public.
  • For single-site projects, excludeRequestHeaders includes 'host' if the two applications run on different hosts.
  • Uploads are stored in persistent or cloud storage.
  • The reverse proxy's client_max_body_size allows the largest file editors upload, including in the HTTPS server block. Test by uploading an image larger than 1 MB.
  • Each application runs at least two processes under a process manager, configured to restart after a reboot (pm2 save and pm2 startup). Test by rebooting the server.
  • Test the production build locally with npm run build followed by npm run serve-backend and npm run serve-frontend before deploying.