Aphex
Deployment

Render

One-click deploy to Render with the bundled render.yaml blueprint — one container, one disk, no database to provision.

Render reads a render.yaml blueprint from the repository, creates everything in it, and generates the secrets that need generating. Both templates ship one, so deploying your own project is close to a single click.

Push your project to GitHub. The render.yaml came with the template, so there is nothing to add.

In Render, choose New → Blueprint and pick your repo.

Render reads the blueprint, creates the web service and its disk, and generates the three secrets. Nothing to paste in.

This works with a private repo, which is why it's the path here rather than the one-click button below.

Just want to see one running? There are buttons for that — the base template and the website template. Each deploys the Aphex template repo it names, not your code, so you can't change the content model and you can't push to what's running — and a ?repo= link only reaches public repos. Treat it as a demo, not a starting point.

What the blueprint creates

One web service and one 5 GB disk mounted at /data. No database service, because the SQLite file lives on that disk next to the uploads:

render.yaml
services:
  - type: web
    name: aphex
    runtime: docker
    plan: starter
    healthCheckPath: /healthz
    disk:
      name: aphex-data
      mountPath: /data
      sizeGB: 5
    envVars:
      - key: AUTH_SECRET
        generateValue: true
      - key: APHEX_SECRET_ENCRYPTION_KEY
        generateValue: true
      - key: APHEX_ASSET_SIGNING_SECRET
        generateValue: true
      - key: APHEX_SQLITE_URL
        value: file:/data/aphex.db
      - key: APHEX_UPLOADS_DIR
        value: /data/uploads
      - key: APHEX_EMBEDDED_WORKER
        value: 'true'

AUTH_URL is deliberately absent. Render doesn't know the hostname until it has created the service, so the container entrypoint reads Render's own RENDER_EXTERNAL_URL on first boot and derives AUTH_URL and ORIGIN from it.

Copy the three generated secrets somewhere durable before you do anything else. Render generates them once, and they must stay stable for the life of the instance: rotating AUTH_SECRET signs everyone out and invalidates every API key. Render's env editor is not a backup.

After the first deploy

Sign up at /login immediately. The first account becomes super admin, and the URL is public from the moment the deploy finishes. If you'd rather not race, set APHEX_BOOTSTRAP_EMAIL to your address in the blueprint before deploying.

Add email. In Render's Environment tab: RESEND_API_KEY and APHEX_EMAIL_FROM. Not in the blueprint because a blueprint can't leave a value blank, and a placeholder key is worse than no key — the app builds a live client around it and every password reset then fails silently at send time.

Once mail is arriving, add AUTH_REQUIRE_EMAIL_VERIFICATION=true.

Add your custom domain, then set AUTH_URL to it explicitly.

The derived value follows RENDER_EXTERNAL_URL, which stays the onrender.com hostname. Leave it and sign-in keeps working on the old URL while failing on the new one, and password-reset emails point at onrender.com.

AUTH_URL=https://cms.example.com
AUTH_TRUSTED_ORIGINS=https://cms.example.com,https://example.com

Costs and the free plan

The blueprint specifies plan: starter (~$7/month) because Render's free tier has no persistent disk — and without a disk this app has nowhere to keep its database. The disk itself is billed per GB on top.

Free instances also spin down after 15 minutes of inactivity, so the next request pays a 30–60 second cold start. Fine for a look at the admin; not for a site anyone else uses.

The disk trade-off

A disk attaches to one instance at a time, which has two consequences worth knowing before you commit:

  • Deploys are a short stop-then-start, not a rolling swap. The new instance can't mount the disk until the old one lets go, so there are a few seconds of downtime on every deploy.
  • You cannot scale past one instance. The scale slider is not a thing you can use while the database is a file on this disk.

Both are the price of not running a database. When either starts to matter, switch to the Postgres variant below.

Postgres instead

The templates ship a second blueprint, render.postgres.yaml, which declares a managed Render Postgres, binds its connection string into the app, and mounts no disk at all — so Render can run several instances and swap them without downtime.

Render only reads render.yaml at the repository root, so swap the files and push:

mv render.postgres.yaml render.yaml
git commit -am "chore: switch to the Postgres blueprint" && git push

What it changes:

render.postgres.yaml (excerpt)
databases:
  - name: aphex-db
    databaseName: aphex
    user: aphex
    plan: basic-256mb

services:
  - type: web
    numInstances: 1
    preDeployCommand: node node_modules/@aphexcms/cms-core/dist/cli/index.js migrate
    envVars:
      - key: APHEX_DATABASE
        value: postgres
      - key: DATABASE_URL
        fromDatabase:
          name: aphex-db
          property: connectionString
      - key: APHEX_DB_AUTO_MIGRATE
        value: 'false'
      - key: APHEX_SKIP_MIGRATE
        value: 'true'
      - key: APHEX_EMBEDDED_WORKER
        value: 'true'
      - key: S3_ENDPOINT
        sync: false
      # …S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_PUBLIC_URL

Three things follow from it, all deliberate:

  • A bucket is now required. With no disk, uploads written to local storage go into a container filesystem the next deploy discards. The S3_* variables are marked sync: false, so Render prompts you for them at deploy time — fill them in before the first upload. Any S3-compatible bucket works; Cloudflare R2 has a free tier and no egress fees.
  • Migrations run as a pre-deploy step, before traffic moves to the new release. Silencing the automatic paths takes two variables, not one: APHEX_DB_AUTO_MIGRATE=false stops the app migrating on boot, and APHEX_SKIP_MIGRATE=true stops the container entrypoint doing it. They are separate code paths — set only one and the other still runs, duplicating the pre-deploy step on every container start.
  • APHEX_EMBEDDED_WORKER=true is set, because this blueprint ships numInstances: 1 and something has to drain the queue — without it scheduled publishes and event consumers never run, and nothing reports an error, since the work is only ever not picked up. Raising numInstances above 1? Remove it, or every instance runs its own loop: set APHEX_WORKER_SECRET and drive the worker endpoint from a Render Cron Job instead — see Operations.

Costs more, obviously: you are now paying for a database and a bucket on top of the web service. Worth it for rolling deploys, more than one replica, or a database you want to reach from somewhere else — not for durability alone, since a disk is not less safe than a managed Postgres on the same provider.

Moving to Postgres does not carry your content across, and moving to a bucket does not carry your media across. Both are clean-slate switches unless you migrate the data yourself. Decide before you have content worth keeping, or plan the migration.

Troubleshooting

Sign-up returns a 404. The origin mismatch described in what a deploy needs. Check the deploy log for the entrypoint's "derived" line; if it isn't there, set AUTH_URL by hand.

Media disappeared after a deploy. APHEX_UPLOADS_DIR isn't pointing at /data, so uploads went into the container filesystem. Those files are gone; fix the variable before uploading more.

Scheduled publishes never happen. APHEX_EMBEDDED_WORKER is not true. The jobs are still queued in the database, so they run once it is set.

The build fails on pnpm install. Render is building the Dockerfile, which expects a lockfile in the repository. A repo published without pnpm-lock.yaml installs unpinned; commit the lockfile.

Edit on GitHub

Last updated on