Deployment guide

Payload CMS 3.92.1

Overview

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 SecureAnyCloud AMI ships Payload CMS 3.92.1 on Ubuntu 24.04 as Payload's blank project, with two collections ready for you to extend: Users (who can sign in) and Media (uploads). It is one version of the SecureAnyCloud CMS, Blogs and Wikis listing on AWS Marketplace, selected as the software version when you subscribe and launch.

What you get:

  • 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. 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 are generated on your instance's first boot, and nothing from the image build is reused. The Next.js keys are 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. 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 SecureAnyCloud's payload-set-hostname helper. nginx answers only that name and the instance's own addresses, and 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, until you configure a mail adapter (Step 10). Payload's default would write password reset links into the system journal. 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, and 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, which 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 the fixed 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 port 22 limited to your own networks, and open port 443 to your editors and API clients.
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, select your Region, and choose Launch through EC2. Choose m5.large, your key pair and a security group as described above.

Alternatively, launch from 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}]'
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>
3

Check the services

First boot takes about a minute, because it creates the database and the administrator. Afterwards, nginx, Payload and the first-boot unit are all active:

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

nginx serves ports 80 and 443, and Payload listens on the loopback address only. The only open ports are therefore 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

Check 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
4

Get your sign-in details

The credentials file holds the address Payload is served on (your instance's public DNS name by default), the admin panel address, and the administrator's e-mail and password. All of these are generated on this instance. Print the file 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. Once you have configured mail (Step 10), change it to your own address in the admin panel (Users, then the administrator), because password reset mails go to that address.

5

Sign in to the admin panel

Open PAYLOAD_ADMIN_URL in your browser. The certificate is self-signed and generated on your instance, so your browser warns you 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. Alternatively, install a certificate from your own certificate authority (Step 9).

Sign in with PAYLOAD_ADMIN_EMAIL and PAYLOAD_ADMIN_PASSWORD. The dashboard lists the project's collections, Users and Media.

Users holds the accounts that can sign in to the admin panel and the API. A fresh instance has exactly one, the administrator.

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>.

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, 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 accepts 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
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. Verify each setting:

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 settings come from the project configuration, where you can change them:

grep -nE "telemetry: false|email: sacNoEmail|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: sacNoEmail, 56: disableIntrospectionInProduction: true, 57: disablePlaygroundInProduction: true, 73: push: false,

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

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. Review the configuration and the file permissions:

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. Alternatively, copy the database consistently with SQLite's own backup command, then 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 safe. 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.

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. Verify all three:

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

The status 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. Instance metadata is blocked for Payload and nginx. Confirm the service settings:

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. Confirm it:

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 one user, the administrator. First boot created it 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
10

Your own DNS name, certificate and mail

To serve Payload under your own DNS name, first point an A record at the instance's Elastic IP. Then run:

sudo payload-set-hostname cms.example.com

The helper 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/sac/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: sacNoEmail in src/payload.config.ts with it, then rebuild (Step 11).

11

Passwords, schema changes and upgrades

To change the administrator's password, run the command below. It writes a new random password to the credentials file and ends every session of that user:

sudo payload-set-password

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

Changing the schema (collections, fields, access rules) or upgrading Payload means rebuilding 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 run:

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 that Payload uses (/etc/payload/payload.env). Test schema changes on a copy of your database first.

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/sac-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/.

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 the password secret, and rotate it if it may have been shared (Step 11).
  • Use your own DNS name and a certificate that 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 loads the Monaco editor from cdn.jsdelivr.net in the browser of whoever edits a Code or JSON field. The shipped collections have no such fields.

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 SecureAnyCloud configuration, first boot and helpers described above, and with Next.js and sharp raised to their fixed releases. One installed dependency, happy-dom 20.14.6, has published advisories that are fixed only in a later major version. It is used only by an optional part of the Lexical editor that Payload does not load, and it is never loaded on this image.

The AMI price covers SecureAnyCloud's hardening, first-boot setup and support.

  • Email: sales@secureanycloud.com
  • Website: https://secureanycloud.com
  • Support: 24/7, with a 24 hour response SLA

Deploy on AWS

Launch Payload CMS 3.92.1 on Ubuntu 24.04 on AWS with 24/7 support from SecureAnyCloud.

View on Marketplace

Need Help?

Our support team is available 24/7.

sales@secureanycloud.com

Deploy on AWS