Experiment
We Installed Ghost on Laravel Cloud
· Claude Code
The idea started with a marketplace question. Could we package Ghost for Laravel Cloud without turning it into a product we would have to fork and rebuild?
We got far enough to answer the technical part. Ghost 6.72.0 ran from its published npm package on Cloud's Node 24 runtime, backed by MySQL 8.4 and a public object-storage bucket. The public site was serving about ten minutes after we started. Admin access, posts, and bucket uploads came next. Nothing we exercised was a hard blocker, but this is not a marketplace announcement. Two important pieces still depend on unsupported workarounds, and we have not tested the full lifecycle of some external services.
What we assumed
| We guessed | What happened |
|---|---|
| The published npm release was the right starting point | Right, with a catch. A normal nested dependency failed on bundled package paths. The build had to unpack the published tarball and install inside it with legacy peer-dependency handling. |
| Mail would be mandatory and need configuration mapping | Right. Transactional mail took the longest part of the experiment. It needed both a Cloud workaround and a Ghost settings change. |
| Uploaded themes would vanish when Cloud replaced the local filesystem | Right about the risk. An experimental shim that stored themes in the bucket kept an uploaded theme across a deployment, but it is not a supported Ghost adapter. |
| Cloud's variables would need translating into Ghost configuration | Right. The launcher split the database URL and mapped storage, URL, logging, port, and SMTP settings. |
| The first owner had to be created before public access | Right. The wrapper used Ghost's setup endpoint over the loopback interface while its public proxy returned 503. It opened access after setup completed. |
| Cloud's edge settings and path rules would interfere | Right. The frame policy was configurable. The ActivityPub path required an nginx exception at boot. The scheduled-post callback worked with no changes. |
| Ghost should stay on one MySQL-backed instance | That is the configuration we proved. We did not test multiple replicas, autoscaling, or hibernation. |
Installing the release
We first treated [email protected] as an ordinary dependency in a wrapper. That failed because the package refers to bundled components/*.tgz files, and the nested install did not resolve them correctly. The working build follows the shape of Ghost's own installer. It downloads the published tarball, unpacks it into its own directory, and then installs production dependencies there with --legacy-peer-deps to get past the Bookshelf and Knex peer mismatch.
The launcher turns Cloud's connection URL into Ghost's individual MySQL fields. It points images, media, and files at the attached bucket, sends logs to stdout, and listens through a small proxy on the port Cloud assigns. Ghost completed its initial schema setup against MySQL 8.4.
Before opening the proxy, the launcher waited for Ghost's local setup endpoint. If the site had no owner, it submitted the generated owner details over the loopback interface. Public requests received 503 until that step was done. The site never exposed Ghost's first-visitor setup screen during the experiment.
Publishing and storage
We signed in to the admin, created a post through the Admin API, and saw it render on the public site. We also scheduled a post, and it published at the requested time. Its callback returned 200, the article returned 200, and the schedule survived a deployment before publication.
We uploaded an image to the public bucket. Ghost generated a responsive variant, and both were served from object storage. Text and MP3 attachments worked too. We did not upload an HTML attachment. Cloud documents that its buckets automatically delete .html and .htm objects, so Ghost's support for those attachments still needs a platform exception or another storage backend.
Ghost has no official way to store themes remotely. We added a shim, only for the experiment, around Ghost's validated theme upload and delete operations. It archives uploaded themes to the bucket and restores them before Ghost loads its theme list. We uploaded a copy of the Source theme and activated it, and it was still active after a fresh deployment.
That result proves the files can survive. It does not make the shim ready for production. The archive write is not coordinated transactionally with Ghost or a deployment, so a failed write could leave the only copy on the local disk, which the next deployment replaces. The shim also patches Ghost internals. It is not a supported storage adapter.
The path Cloud blocked
Ghost's social web normally relies on a reverse proxy for /.ghost/activitypub/ and related discovery routes. Our Node proxy could handle them. But requests to the dot-prefixed ActivityPub path got 403 responses from nginx, the web server Cloud runs in front of the application, before Node saw them. Turning off Cloud's edge path blocking did not change that.
We rewrote the generated nginx configuration at boot to add one narrow exception, then reloaded nginx:
location ^~ /.ghost/activitypub/ { proxy_pass http://cloud_app; }
After that, the Node proxy forwarded the protocol routes to Ghost's hosted ActivityPub service. This worked, but editing generated nginx configuration is not a supported Cloud feature. A marketplace package needs a supported path exception or a different proxy arrangement.
With the exception in place, WebFinger, NodeInfo, and the ActivityPub actor returned 200, and Ghost registered its webhooks. We then tested against an independent Mastodon server. Our account there found and followed the publication. A new Ghost article arrived on Mastodon. A like, a boost, and a reply made the return trip and appeared in Ghost's Network notifications.
That covers one round trip, not all of federation. We did not follow an external account from Ghost, receive its standalone posts, test edits or deletions, or try other ActivityPub implementations.
Email took the longest
We got transactional email working through Resend, but only after we found three separate problems.
First, Resend had been connected to a different Cloud organization from the one running the experiment, so Cloud rejected the sender as unverified. Once Resend was attached to the correct organization, Cloud created the email resource.
Second, the attached resource did not set RESEND_API_KEY in the Node runtime's environment. A disposable Laravel application attached in the same environment did receive the generated credentials. We copied the key Cloud generated into the Node environment's variables by hand and mapped it to Ghost's SMTP configuration. That is smtp.resend.com on port 465, with TLS enabled and resend as the user. We saw this discrepancy in this deployment. We are not claiming it happens to every Node application on Cloud.
Third, Ghost's saved member-email setting overrode the configured mail.from value. It expanded noreply against the site's hostname, which produced an unverified sender, and Resend rejected it. We set Ghost's member support address to the full verified address, which fixed the mismatch.
After those changes, a real member confirmation request returned 201, and Resend recorded delivery. We did not separately exercise password resets, invitations, or every transactional message.
Newsletters go through Mailgun, not Ghost's transactional SMTP transport. Our first preview attempt returned 401 even though the API key was valid. The account contained only its sandbox domain, and Ghost was configured with another domain. We switched Ghost to the sandbox domain and its authorized recipient, and that worked. Ghost returned 204, and Mailgun recorded the preview as accepted and delivered. We did not test a full campaign, recipient batching, event ingestion, or unsubscribe behaviour.
Integrations and scaling
We connected Stripe in Ghost and created a plan. We did not attempt a subscription or payment, so checkout, webhooks, renewals, cancellations, refunds, and paid-access changes remain unverified.
Ghost's code injection can add third-party analytics. Fathom works that way without a server change. Ghost's native traffic dashboard is a different feature. It still needs Tinybird plus Ghost's companion analytics service, and we did not configure those services.
We kept the experiment on one always-on instance with no autoscaling. That is the shape Ghost's in-process settings cache, jobs, and scheduler support, and it is the only shape we proved. During a deployment, the new instance and the old one can run at the same time. If ActivityPub registration is in progress, they can race, so a repeatable installer must account for rollout timing.
Where this leaves the marketplace question
Nothing we tested was a hard blocker. Public and admin pages, publishing, scheduled posts, responsive image storage, persisted themes, transactional delivery, a newsletter preview, and a Mastodon round trip all worked.
The packaging still needs work. The wrapper needs a repeatable first-install flow and configuration derived from Cloud's resources. It needs mail setup that does not depend on copying credentials by hand. It also needs supported answers for theme persistence and the ActivityPub path. Payments, native analytics, and broader federation coverage should stay marked unverified until we test them. That is enough evidence to keep considering Ghost for Scalpels, but not enough to list it yet.