Deployment

Deploying Adonis EOS is similar to deploying any standard Node.js application. You need a server running Node.js >= 20.6 and a PostgreSQL database.

This guide provides the path of least resistance to getting your project running in a production environment.

1. Create a Production Build

The first step is to compile your TypeScript code and frontend assets (React/Inertia) into a production-ready format.

bash
node ace build

The compiled output is written to the ./build directory. From this point forward, the build folder is the root of your application. For ESM aliases to resolve correctly, you should change into the build directory before running commands.

2. Configure Environment Variables

In production, you should never use a local .env file committed to version control. Instead:

  • Platform-managed: If using a cloud platform (e.g. DigitalOcean App Platform, Heroku), use their dashboard to set environment variables.

  • Server-based: Place a secure .env file in a protected directory (e.g., /var/www/adonis-eos/.env) and point your app to it using ENV_PATH.

⚠️ Critical Note on .env Syntax: Many production environment parsers (including PM2) can be sensitive to trailing comments. Ensure your variables do not have comments or extra spaces on the same line.

Bad: STORAGE_DRIVER=r2 # Use R2 for storage

Good: STORAGE_DRIVER=r2

Essential Production Variables

Variable

Description

NODE_ENV

Must be set to production.

APP_KEY

Generate a fresh one with node ace generate:key.

HOST

Set to 0.0.0.0 to bind to all network interfaces.

PORT

The port your app will listen on (default 3333).

LOG_LEVEL

Logging verbosity (e.g., info or error).

SESSION_DRIVER

Set to cookie for production persistence.

DB_*

Your production PostgreSQL credentials.

DB_SSL

Set to true for managed databases (e.g. Neon).

DB_SSL_REJECT_UNAUTHORIZED

Set to false if your provider uses self-signed certs.

REDIS_HOST

Required if using Redis for caching or sessions.

REDIS_PORT

Port for your Redis instance (default 6379).

REDIS_PASSWORD

Optional password for Redis authentication.

STORAGE_DRIVER

Set to r2 for S3-compatible storage or local.

R2_ENDPOINT

The S3-compatible endpoint (e.g., https://<id>.r2.cloudflarestorage.com).

R2_ACCESS_KEY_ID

API Access Key ID.

R2_SECRET_ACCESS_KEY

API Secret Access Key.

R2_BUCKET

The name of your bucket.

R2_PUBLIC_BASE_URL

The public URL of your bucket.

SMTP_*

Credentials for your email provider (for notifications).

MAIL_MAILER

The active mailer (e.g., resend or smtp).

MAIL_API_KEY

API key for your email provider (currently supports Resend).

CORS_ORIGINS

Comma-separated list of allowed origins (e.g., https://example.com). Required in production.

PROTECTED_ACCESS_USERNAME

Username for the "Protected Access" layer and initial data splash page.

PROTECTED_ACCESS_PASSWORD

Password for the "Protected Access" layer and initial data splash page.

3. Database & Initial Data

Database Setup

If you are running your own PostgreSQL instance (e.g., on Hetzner), you must manually create the database before running migrations:

bash
# Connect as the postgres user
sudo -u postgres psql

# Create the database
CREATE DATABASE adonis_eos;
\q

Initial Data Seeding

Adonis EOS follows a convention for first-time production launches using a production-export.json file.

⚠️ Security Warning: production-export.json contains your entire site structure and content. NEVER commit this file to GitHub or any other public repository. It is included in .gitignore by default.

Method A: Splash Page Upload (Recommended for Fresh Installs)

If you have configured PROTECTED_ACCESS_USERNAME and PROTECTED_ACCESS_PASSWORD, you can upload your export file directly through the web interface on a fresh installation:

  1. Deploy your application and run migrations:

    bash
    ENV_PATH=./ node build/ace migration:run --force
  2. Navigate to your site's root URL (e.g., https://yourdomain.com).

  3. Since no content exists, you will see a "Welcome" splash page.

  4. Enter your protected access credentials and select your production-export.json file.

  5. Click Start Import. The site will automatically refresh once the data is loaded.

Method B: CLI Seeding

Alternatively, you can seed the data via the command line:

  1. Ensure database/seed_data/**/* is included in the metaFiles array of your adonisrc.ts.

  2. Securely transfer your production-export.json to database/seed_data/production-export.json on the server (e.g., via SCP or SFTP).

  3. Run the production seeder from the project root:

bash
ENV_PATH=./ node build/ace db:seed --files ./database/seeders/production_import_seeder.js

Note: The seeder includes a safety check that allows up to 10 existing users (to account for system users created on boot) but will abort if it detects existing posts or menus to prevent data loss.

4. Common Production Pitfalls

ESM Alias Resolution (ERR_MODULE_NOT_FOUND)

If you see errors stating that a module starting with # cannot be found, it is almost always because the command is being run from the project root instead of the build folder. In production, the package.json inside the build folder contains the correct mappings for compiled code.

FFmpeg (Video Optimization)

If you plan to use video optimization features, ffmpeg must be installed on your production server.

  1. Install FFmpeg:

    • Ubuntu/Debian: sudo apt update && sudo apt install -y ffmpeg

    • RHEL/CentOS: sudo dnf install ffmpeg

  2. Verify PATH: Ensure ffmpeg and ffprobe are in your system PATH.

  3. Custom Paths: If ffmpeg is installed in a non-standard location, you can specify the paths in your .env:

    typescript
    FFMPEG_PATH=/usr/local/bin/ffmpeg
    FFPROBE_PATH=/usr/local/bin/ffprobe

R2 Signature Mismatch & Clock Drift

If R2 uploads fail with a "Signature Mismatch" error:

  1. Check Server Time: Ensure your server clock is synced. Cloudflare rejects requests if the time drift is > 5 minutes. Use timedatectl set-ntp on.

  2. Clean Keys: Ensure your R2_SECRET_ACCESS_KEY has no trailing spaces or hidden carriage returns (\r).

  3. Fresh Token: If in doubt, generate a brand new API token in the Cloudflare dashboard.

Media (Video/MP4) Not Rendering

If videos appear as broken links but the URL is correct:

  1. MIME Type: Ensure the file in R2 has Content-Type: video/mp4 (not application/octet-stream).

  2. CSP: Ensure your config/shield.ts includes the R2 domain in the mediaSrc directive.

413 Payload Too Large (Imports)

If the initial data import fails during upload:

  1. Nginx: Increase client_max_body_size in your Nginx config (see example below).

  2. Bodyparser: Ensure config/bodyparser.ts has a high enough multipart.limit (e.g., 100mb).

Database SSL

Most managed cloud databases require SSL. If migrations fail to connect, ensure DB_SSL=true and DB_SSL_REJECT_UNAUTHORIZED=false are set.

Email Delivery (Hetzner/DigitalOcean)

Cloud providers like Hetzner and DigitalOcean often block outbound traffic on SMTP port 25 to prevent spam.

  1. Use API-based Sending: We recommend using the Resend mailer (MAIL_MAILER=resend). It uses HTTP APIs instead of SMTP, bypassing these port restrictions.

  2. Verify Domain: Ensure you have added the required DKIM/SPF records to your DNS (e.g., in Cloudflare) as provided by your mail provider.

Running Migration Commands in Production

If node build/ace migration:run fails to find your environment variables, you can "force" them by injecting them directly:

bash
# Example of "forced" environment injection for production commands
NODE_ENV=production \
PORT=3333 \
HOST=0.0.0.0 \
CORS_ORIGINS="https://yourdomain.com" \
APP_KEY="your-key" \
DB_HOST="127.0.0.1" \
DB_USER="postgres" \
DB_PASSWORD="your-password" \
DB_DATABASE="adonis_eos" \
STORAGE_DRIVER=r2 \
R2_PUBLIC_BASE_URL="https://media.yourdomain.com" \
node build/ace migrate:media:r2

Note: The production seeder will automatically abort if it detects existing data in key tables (users, posts, etc.) to prevent accidental data loss.

⚠️ Safety: Disable Rollbacks

Rolling back in production is dangerous. We recommend disabling it in config/database.ts:

typescript
// config/database.ts
{
  pg: {
    client: 'pg',
    migrations: {
      disableRollbacksInProduction: true,
    }
  }
}

4. Services

Redis

Redis is strongly recommended for production environments. It is used for:

  • Server-Side Rendering (SSR) Caching: Drastically improves performance by caching rendered pages.

  • Session Management: Shared sessions across multiple application instances.

  • Rate Limiting: Accurate tracking of request rates.

Ensure REDIS_CACHE_ENABLED=true is set in your environment variables to enable the caching layer.

Persistent Storage

Since Adonis EOS handles heavy media uploads, you must use persistent storage. Default local storage is ephemeral on many cloud platforms.

  • Recommended: Use Cloudflare R2 (or S3-compatible). Set STORAGE_DRIVER=r2 and provide your bucket credentials.

  • Local Alternative: Use a Persistent Volume if your host supports it and set STORAGE_LOCAL_ROOT to that mount point.

Migrating Local Media to R2

If you have been using local storage during development and want to migrate your media to Cloudflare R2 for production:

  1. Preparation: Ensure you have a local copy of all files in public/uploads.

  2. Configuration: Ensure STORAGE_DRIVER=r2 and all R2_* variables are set in your production environment.

  3. Public Networking: Managed databases usually provide both internal and public hostnames. Since you are migrating local files to a production database, you must use the Public Hostname/Port and ensure SSL is enabled.

  4. Migration: Run the migration command locally, providing your production database and R2 credentials. This will upload local files to R2 and update the production database records.

bash
# Example: Running migration locally against a production database
DB_HOST=your-public-db-host.com \
DB_PORT=your-public-port \
DB_USER=your-user \
DB_PASSWORD=your-password \
DB_DATABASE=your-db-name \
DB_SSL=true \
DB_SSL_REJECT_UNAUTHORIZED=false \
STORAGE_DRIVER=r2 \
R2_ENDPOINT=https://your-endpoint.com \
R2_ACCESS_KEY_ID=your-key \
R2_SECRET_ACCESS_KEY=your-secret \
R2_BUCKET=your-bucket \
R2_PUBLIC_BASE_URL=https://media.yourdomain.com \
node ace migrate:media:r2 --dry-run

Note: Do not use platform-specific CLIs (like railway run) for this migration if they inject internal hostnames, as they will prevent your local machine from connecting to the database. Provide the public credentials directly as environment variables instead.

5. Health Checks

Adonis EOS includes a built-in health check endpoint for load balancers and uptime monitoring:

  • Endpoint: /health

  • Response: Returns a 200 OK with JSON indicating status, uptime, and timestamp.

6. Process Management (PM2)

Use a process manager like PM2 to keep your application running in the background and restart it if it crashes. A pre-configured ecosystem.config.cjs is included in the project root.

Start your app

bash
# Recommended: Ensure you are in the build directory for ESM resolution
cd build && pm2 start ../ecosystem.config.cjs

7. Automated Deployment (GitHub Actions)

Adonis EOS includes a GitHub Actions workflow (.github/workflows/deploy.yml) for automated deployments to a VPS (like Hetzner, DigitalOcean, or Linode) upon pushing to the main branch.

Prerequisites

  1. GitHub Secrets: Add the following secrets to your repository:

    • HOST: Your server's IP address.

    • USERNAME: Your SSH username (e.g., root).

    • SSH_PRIVATE_KEY: Your private SSH key.

  2. Environment File: Ensure a .env file exists on the server at /var/www/adonis-eos/.env (or your configured path).

Workflow Overview

The workflow performs the following steps:

  • Checks out the code.

  • Installs dependencies and runs the build.

  • Syncs the build folder, package.json, and ecosystem.config.cjs to the server.

  • Runs production migrations (via cd build && node ace ...).

  • Restarts the application via PM2.

8. Serving Static Assets

For the best performance, offload the task of serving static assets (images, CSS, JS) to a Reverse Proxy (Nginx) or a CDN.

Nginx Example

nginx
server {
    listen 80;
    server_name yourdomain.com;

    location / {
        proxy_pass http://localhost:3333;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
        
        # Increase upload limit for data imports
        client_max_body_size 100M;
    }

    # Serve static assets directly via Nginx
    location ~ \.(jpg|png|css|js|gif|ico|woff|woff2) {
        root /var/www/adonis-eos/build/public;
        add_header Cache-Control "public, max-age=31536000";
    }
}

Related: Installation | Update Philosophy