Applications AWS

Payload CMS 3.92.1 on AWS User Guide

| Product: Payload CMS 3.92.1 on Ubuntu 24.04 on AWS

Overview

This guide covers launching and using the Payload CMS 3.92.1 on Ubuntu 24.04 AMI from cloudimg on Amazon EC2. It is one version of the cloudimg CMS, Blogs and Wikis listing on AWS Marketplace; you pick it as the software version when you subscribe and launch.

Payload is a free and open source headless content management system built on Next.js. You describe your content in TypeScript (collections, fields, access rules), and Payload gives you an admin panel at /admin, a REST API under /api, a GraphQL API at /api/graphql, a Lexical rich text editor, uploads with image resizing, versions and drafts. This image ships Payload's blank project with two collections, Users (who can sign in) and Media (uploads), ready for you to extend.

The cloudimg image adds:

  • No setup page anyone could claim. A fresh Payload project shows a "create first user" page to whoever opens the admin panel first. On this image your instance's first boot creates the first administrator itself, with a password generated on your instance, before the web server starts, so that page is never open.
  • Secrets of your instance's own, never on a command line. The Payload secret (which signs every sign-in session), the administrator password, the TLS key and the Next.js keys (the Server Actions encryption key and the draft-mode keys, which a Next.js build would otherwise bake into the image and share between every instance) are generated on your instance's first boot; nothing from the image build is reused. The password reaches Payload in a file only Payload can read, never as a command line argument or an environment variable.
  • HTTPS from the first boot. nginx serves Payload on port 443 with a certificate generated on your instance's first boot. Port 80 redirects to HTTPS. Payload itself listens only on 127.0.0.1:3000.
  • Host from configuration, never from the request. Payload's server URL (used for every absolute link) is your instance's public DNS name by default, set by cloudimg's payload-set-hostname helper. nginx answers only that name and the instance's own addresses; any other host name gets no answer. Payload honours the admin's session cookie only for requests from that URL.
  • Closed by default. Payload's usage telemetry is off. No mail is sent, and no mail is written to the log (Payload's default would write password reset links into the system journal), until you configure a mail adapter (Step 10). The GraphQL playground and GraphQL schema introspection are off.
  • Sign-in protection. nginx limits every sign-in, password reset and account route to 10 requests a minute per client address, and GraphQL requests to 60 a minute. Payload locks an account for 10 minutes after 5 wrong passwords. The session cookie is Secure, HttpOnly and SameSite=Lax.
  • Sandboxed upload files. Every file under /api/<collection>/file/ gets nosniff and a sandboxing content security policy; only passive types (images, PDF, fonts, audio and video) open in the browser, anything else is sent as a download. Payload itself refuses HTML uploads and SVG files that carry scripts.
  • A hardened service. Payload runs as its own unprivileged user. The application in /opt/payload belongs to root and is read only to Payload; Payload can write only its data folder, /var/lib/payload. Instance metadata (169.254.169.254) is blocked for Payload and nginx.
  • Current, checked dependencies. Payload 3.92.1's template pins Next.js 16.3.3 and sharp 0.35.4, which have published security fixes in Next.js 16.4.3 and sharp 0.35.5; this image ships those releases. Only the Node.js runtime itself ships, not npm.
  • Fail closed. nginx and Payload do not start until first boot has finished; if first boot cannot complete, the site stays offline rather than open.

Prerequisites

  • An AWS account subscribed to the listing, an EC2 key pair and a VPC subnet with a route to the internet (or a private route you use for management).
  • A security group that allows TCP 22 (SSH), TCP 443 (Payload) and TCP 80 (redirects to HTTPS) from the networks that should reach them. Keep 22 to your own networks. Open 443 to your editors and API clients.

Step 1: Launch the instance

In the AWS Marketplace listing choose Continue to Subscribe, then Continue to Configuration, pick Payload CMS 3.92.1 on Ubuntu 24.04 as the software version and your Region, then Launch through EC2. Choose m5.large, your key pair and a security group as above.

With the AWS CLI (replace the values in angle brackets):

aws ec2 run-instances --region <region> --image-id <ami-id-from-the-listing> \
  --instance-type m5.large --key-name <your-key-pair> \
  --security-group-ids <your-security-group> --subnet-id <your-subnet> \
  --metadata-options HttpTokens=required,HttpEndpoint=enabled \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=payload-cms}]'

Step 2: Connect to your instance

Version SSH user
Payload CMS 3.92.1 on Ubuntu 24.04 ubuntu
ssh -i <your-key.pem> ubuntu@<public-ip>

Step 3: Check the services

First boot takes about a minute (it creates the database and the administrator). Then nginx, Payload and the first boot unit are active:

systemctl is-active nginx payload payload-firstboot
active
active
active

nginx serves ports 80 and 443; Payload listens on the loopback address only, so the only open ports are 22, 80 and 443 (port 53 on 127.0.0.53/54 is the system's local DNS resolver):

sudo ss -tlnH | awk '{print $4}' | sort -V
0.0.0.0:22
0.0.0.0:80
0.0.0.0:443
127.0.0.1:3000
127.0.0.53%lo:53
127.0.0.54:53
[::]:22
[::]:80
[::]:443

The Node.js, Payload, Next.js, nginx and SQLite versions:

/opt/node/bin/node --version
cd /opt/payload && for m in payload next react sharp @payloadcms/db-sqlite @payloadcms/richtext-lexical; do printf '%s %s\n' "$m" "$(/opt/node/bin/node -p "require('./node_modules/$m/package.json').version")"; done
nginx -v 2>&1
sqlite3 --version | cut -d' ' -f1
v24.22.2
payload 3.92.1
next 16.4.3
react 19.2.6
sharp 0.35.5
@payloadcms/db-sqlite 3.92.1
@payloadcms/richtext-lexical 3.92.1
nginx version: nginx/1.24.0 (Ubuntu)
3.45.1

Step 4: Get your sign-in details

The credentials file holds the address Payload is served as (your instance's public DNS name by default), the admin panel address and the administrator's e-mail and password, all generated on this instance. Show it with:

sudo cat /root/payload-cms-credentials.txt
PAYLOAD_URL=https://ec2-<public-ip-dashed>.<region>.compute.amazonaws.com/
PAYLOAD_ADMIN_URL=https://ec2-<public-ip-dashed>.<region>.compute.amazonaws.com/admin
PAYLOAD_ADMIN_EMAIL=admin@example.com
PAYLOAD_ADMIN_PASSWORD=<24 letters and digits>

Payload signs in with an e-mail address. The administrator starts as admin@example.com; change it to your own address in the admin panel (Users, then the administrator) once you have configured mail (Step 10), because password reset mails go to that address.

Step 5: Sign in to the admin panel

Open PAYLOAD_ADMIN_URL in your browser. The certificate is self signed (generated on your instance), so your browser warns once: compare the certificate's SHA-256 fingerprint with the one on the instance (sudo openssl x509 -in /etc/nginx/ssl/payload.crt -noout -fingerprint -sha256) and continue, or install a certificate from your own certificate authority (Step 9). Sign in with PAYLOAD_ADMIN_EMAIL and PAYLOAD_ADMIN_PASSWORD:

The Payload admin sign in page with the Email and Password fields

The dashboard lists the project's collections, Users and Media:

The Payload admin dashboard listing the Users and Media collections

Users holds the accounts that can sign in to the admin panel and the API; a fresh instance has exactly the administrator:

The Users collection in the Payload admin panel with the single administrator account

Step 6: Uploads and content

Open Media and choose Create New to upload a file; the Alt text is required. Payload stores the file in /var/lib/payload/media and serves it from https://<your host>/api/media/file/<name>:

Uploading an image to the Media collection in the Payload admin panel

Uploads are limited to 32 MiB. Payload refuses HTML files and SVG files that carry scripts, and nginx sends every other non-passive type as a download.

Your content model lives in code: the project is in /opt/payload, with the configuration in src/payload.config.ts and the collections in src/collections/. To add collections and fields you edit those files and rebuild the project (Step 11). The front end at https://<your host>/ is the blank template's welcome page; replace src/app/(frontend)/ with your own site, or use Payload as a headless CMS from any front end through its APIs.

The REST API works with the same accounts. For example, from a machine that trusts your certificate (or with -k while you test), sign in and read the current user:

curl -s -c jar.txt -H 'Content-Type: application/json' \
  -d '{"email":"admin@example.com","password":"<your password>"}' https://<your host>/api/users/login
curl -s -b jar.txt -H 'Origin: https://<your host>' https://<your host>/api/users/me

Step 7: The closed-by-default settings

The GraphQL playground and schema introspection are off, the first user setup route is closed, and anonymous clients cannot list or create users:

H=$(sed -n 's/^set \$payload_host "\(.*\)";$/\1/p' /etc/nginx/payload-host.conf)
curl -sk -o /dev/null -w 'GraphQL playground: %{http_code}\n' --resolve "$H:443:127.0.0.1" "https://$H/api/graphql-playground"
curl -sk --resolve "$H:443:127.0.0.1" -H 'Content-Type: application/json' -d '{"query":"{__schema{types{name}}}"}' "https://$H/api/graphql" | cut -c1-95; echo
curl -sk -o /dev/null -w 'first-register: %{http_code}\n' --resolve "$H:443:127.0.0.1" -H 'Content-Type: application/json' -d '{"email":"someone@example.com","password":"Example12345678901234567"}' "https://$H/api/users/first-register"
curl -sk -o /dev/null -w 'anonymous user list: %{http_code}\n' --resolve "$H:443:127.0.0.1" "https://$H/api/users"
GraphQL playground: 404
{"errors":[{"message":"GraphQL introspection is not allowed, but the query contained __schema o
first-register: 403
anonymous user list: 403

These come from the project configuration, where you can change them:

grep -nE "telemetry: false|email: cloudimgNoEmail|csrf:|push: false|disable(Introspection|Playground)InProduction" /opt/payload/src/payload.config.ts | sed -E 's/^([0-9]+): +/\1: /'
32: csrf: serverURL ? [serverURL] : [],
51: telemetry: false,
54: email: cloudimgNoEmail,
56: disableIntrospectionInProduction: true,
57: disablePlaygroundInProduction: true,
73: push: false,

push: false means the database schema comes from migrations (src/migrations/), never from an automatic schema push; that is Payload's recommended setup for production.

Step 8: Your data and configuration

Payload's data is the SQLite database /var/lib/payload/payload.db and the upload folder /var/lib/payload/media; its configuration is /etc/payload/payload.env, and its secret is /etc/payload/payload-secret:

sudo grep -v '^#' /etc/payload/payload.env | sed -E 's#https://[^/]+#https://<your host>#'
sudo stat -c '%n %U:%G %a' /root/payload-cms-credentials.txt /etc/payload /etc/payload/payload.env /etc/payload/payload-secret /opt/payload /var/lib/payload /var/lib/payload/payload.db /var/lib/payload/media
DATABASE_URL=file:/var/lib/payload/payload.db
PAYLOAD_MEDIA_DIR=/var/lib/payload/media
PAYLOAD_SECRET_FILE=/etc/payload/payload-secret
PAYLOAD_SERVER_URL=https://<your host>
/root/payload-cms-credentials.txt root:root 600
/etc/payload root:payload 750
/etc/payload/payload.env root:payload 640
/etc/payload/payload-secret root:payload 640
/opt/payload root:root 755
/var/lib/payload payload:payload 750
/var/lib/payload/payload.db payload:payload 640
/var/lib/payload/media payload:payload 750

To back up, take an EBS snapshot of the volume, or copy the database consistently with SQLite's own backup command and copy the media folder:

sudo -u payload sqlite3 /var/lib/payload/payload.db ".backup '/var/lib/payload/payload-backup.db'"
sudo tar -C /var/lib/payload -czf /root/payload-media.tgz media

Keep the Payload secret: it signs every session, and changing it signs everyone out. To keep the data on a separate EBS volume, stop payload, mount the volume at /var/lib/payload (owner payload:payload, mode 0750), copy the files across and start payload again.

Step 9: Security checks

Port 80 sends browsers to HTTPS, the health endpoint answers through Payload for load balancer checks, and an unknown host name gets no answer:

H=$(sed -n 's/^set \$payload_host "\(.*\)";$/\1/p' /etc/nginx/payload-host.conf)
curl -s -o /dev/null -w 'port 80: %{http_code} -> %{redirect_url}\n' --resolve "$H:80:127.0.0.1" "http://$H/admin" | sed -E 's#https://[^/]+#https://<your host>#'
curl -sk -o /dev/null -w 'admin sign-in page: %{http_code}\n' --resolve "$H:443:127.0.0.1" "https://$H/admin/login"
curl -sk -o /dev/null -w 'health: %{http_code}\n' --resolve "$H:443:127.0.0.1" "https://$H/healthz"
curl -sk -o /dev/null -w 'unknown host name: %{http_code}\n' -H 'Host: example.com' https://127.0.0.1/ || true
port 80: 301 -> https://<your host>/admin
admin sign-in page: 200
health: 200
unknown host name: 000

(000 means nginx closed the connection without an answer.) Payload runs as its own user with a read-only system and only its data folder writable, and instance metadata is blocked for Payload and nginx:

systemctl show payload -p User -p ProtectSystem -p ReadWritePaths -p NoNewPrivileges -p IPAddressDeny
systemctl show nginx -p IPAddressDeny
IPAddressDeny=168.63.129.16/32 169.254.169.254/32 fd00:ec2::254/128
User=payload
ReadWritePaths=/var/lib/payload
ProtectSystem=strict
NoNewPrivileges=yes
IPAddressDeny=168.63.129.16/32 169.254.169.254/32 fd00:ec2::254/128

The certificate generated on first boot is a server certificate, not a certificate authority:

sudo openssl x509 -in /etc/nginx/ssl/payload.crt -noout -ext basicConstraints,extendedKeyUsage | sed 's/^ *//'
X509v3 Basic Constraints: critical
CA:FALSE
X509v3 Extended Key Usage: 
TLS Web Server Authentication

The database holds exactly the administrator, created by first boot after the shipped migration built the schema:

sudo -u payload sqlite3 -readonly /var/lib/payload/payload.db 'SELECT email FROM users;' 'SELECT count(*) FROM payload_migrations;'
admin@example.com
1

Step 10: Your own DNS name, certificate and mail

To serve Payload under your own DNS name (point an A record at the instance's Elastic IP first):

sudo payload-set-hostname cms.example.com

This generates a new certificate for that name, makes nginx answer it, sets Payload's server URL to https://cms.example.com and restarts Payload. To use a certificate from your own certificate authority (for example one you obtain with certbot for your-domain), replace /etc/nginx/ssl/payload.crt and /etc/nginx/ssl/payload.key (root, 0600 for the key) and run sudo systemctl reload nginx. Do not add the self-signed certificate to a trust store; compare its fingerprint instead (Step 5).

Mail: the image's mail adapter (src/cloudimg/no-email.ts) sends nothing, so password reset and verification mails are dropped and only a line saying a message was dropped is logged. To send mail, add Payload's nodemailer adapter (@payloadcms/email-nodemailer) with your SMTP server, replace email: cloudimgNoEmail in src/payload.config.ts with it, and rebuild (Step 11).

Step 11: Passwords, schema changes and upgrades

Change the administrator's password (a new random password is written to the credentials file, and every session of that user ends):

sudo payload-set-password

For another user, sudo payload-set-password --email <user e-mail> prints the new password; with --stdin it reads one (24 to 72 letters and digits) from standard input. This also lifts a lockout after too many wrong passwords.

Changing the schema (collections, fields, access rules) or upgrading Payload is a rebuild of the project, because Payload is a Next.js application. The image ships the Node.js runtime but not npm; to rebuild, install the Node.js 24 toolchain with npm in a working copy, then:

cp -a /opt/payload ~/payload && cd ~/payload
npm ci
# edit src/payload.config.ts or src/collections/*, or raise the payload packages in package.json
npx payload migrate:create <name>
npx next build
sudo systemctl stop payload
sudo rsync -a --delete ~/payload/ /opt/payload/ && sudo chown -R root:root /opt/payload
sudo payload-next-keys
sudo -u payload sh -c 'cd /opt/payload && /opt/node/bin/node node_modules/payload/bin.js migrate'
sudo systemctl start payload

sudo payload-next-keys writes your instance's Next.js keys (/etc/payload/next-keys.env) into the new build, because every next build mints new ones; sudo payload-next-keys --rotate makes new keys. Run the migration command with the same environment Payload uses (/etc/payload/payload.env); test schema changes on a copy of your database first.

Step 12: Server components

Component Where
Payload project (code, root owned) /opt/payload
Node.js 24.22.2 LTS /opt/node/bin/node
Payload service payload.service (next start -H 127.0.0.1 -p 3000, user payload)
Web server nginx.service, site /etc/nginx/sites-available/cloudimg-payload
Data /var/lib/payload (SQLite payload.db, media/, next-cache/)
Configuration and secrets /etc/payload/payload.env, /etc/payload/payload-secret, /etc/payload/next-keys.env
Helpers payload-set-hostname, payload-set-password, payload-next-keys, payload-cred-roundtrip
Package list and licences /usr/share/doc/payload-cms/PACKAGES.txt

Logs: sudo journalctl -u payload (Payload, as JSON lines) and /var/log/nginx/.

Step 13: Security recommendations

  • Allow port 22 in the security group from your own networks only.
  • Configure mail (Step 10), then change the administrator e-mail to your own (Step 4); keep its password secret and rotate it if it may have been shared (Step 11).
  • Use your own DNS name and a certificate your browsers trust (Step 10).
  • Create editor accounts with only the access they need: Payload's access control is code in your collections.
  • Keep Payload on a current release (Step 11) and keep the operating system patched; unattended security upgrades are on, and sudo apt-get update && sudo apt-get upgrade applies everything else.
  • Payload's code editor (used by Code and JSON fields, which the shipped collections do not have) loads the Monaco editor from cdn.jsdelivr.net in the browser of whoever edits such a field.

Support and licensing

Payload is free software under the MIT licence (a copy is at /usr/share/doc/payload-cms/LICENSE.md); Node.js, Next.js, React and the other bundled packages carry their own open source licences (listed in /usr/share/doc/payload-cms/PACKAGES.txt). There is no per user or per CPU licence fee and no licence key. This image ships Payload's blank project with the cloudimg configuration, first boot and helpers described above, and Next.js and sharp raised to their fixed releases. One installed dependency, happy-dom 20.14.6 (used only by an optional part of the Lexical editor that Payload does not load), has published advisories fixed only in a later major version; it is never loaded on this image. The AMI price covers cloudimg's hardening, first boot setup and support.

  • Email: support@cloudimg.co.uk
  • Website: https://www.cloudimg.co.uk
  • Support: 24/7, with a 24 hour response SLA