From Vapor to Brewless without touching your code

The whole migration of a Laravel app away from Vapor: one package in, Vapor's out, one brewless.yml, and your database and files in one command each.

In this post
  1. Do you have to change your application's code to leave Vapor?
  2. The packages
  3. The one new file
  4. What stays exactly as it was
  5. Where code does change
  6. The migration, start to end
  7. Or hand the whole thing to your coding agent
  8. Optional: your database in one command
  9. Optional: your files in one command
  10. The cut-over
  11. So, no code changes?

A Laravel application on Vapor is already serverless: no servers to patch, a container per request, and a vapor.yml that describes every environment. Moving it to Brewless means moving that same shape to Scaleway and bunny.net, in your own accounts. The question that decides whether that is an afternoon or a quarter is how much of the application itself has to change.

For most applications: one package, one file, and the variables of the environment. This post walks the whole migration, and says plainly where code does have to change.

Do you have to change your application's code to leave Vapor?

Almost never. Brewless serves your application with FrankenPHP in classic mode, where every request starts with a clean state, so an application that ran on PHP-FPM and on Lambda runs here as it is. What changes is one package in composer.json, one new file next to vapor.yml, and the variables of the environment.

Two things can still reach into your code: calls to Vapor's own helpers, and a MySQL database that becomes PostgreSQL. Both are under where code does change, with what to do about them.

The packages

Package What to do Why
brewless/laravel composer require brewless/laravel The routes your containers answer on, and trust in the edge
aws/aws-sdk-php nothing Comes along with brewless/laravel, for the sqs connection
laravel/vapor-core remove when you stop deploying to Vapor It is the Lambda runtime
laravel/vapor-cli remove at the same time The vapor command
laravel-vapor (npm) only if your frontend uploads through Vapor See below
laravel/octane leave it out of the serve path Brewless runs FrankenPHP in classic mode and refuses a release that starts Octane

brewless/laravel is MIT, runs in your own containers and keeps working if you stop using Brewless. It adds:

  • the release, schedule and queue routes. Nothing stays running on a serverless container, so the release container answers POST /_ops/migrate with your release commands, and the worker answers a POST / from a trigger of Scaleway every minute with one tick of the schedule and the queue. Every one of these needs the secret in BREWLESS_OPS_SECRET; a container without that variable has none of these routes. Without the package, the release step of your first deploy fails, because the route it calls does not exist.
  • the visitor's hostname and address behind bunny.net. Scaleway routes on the Host header and only knows the container's own name, so the edge carries the real hostname in a header and the package puts it back — only for your own domains, and only with the edge's secret.
  • pages the edge may keep. An answer that cannot depend on who asked gets a Cache-Control the edge honours, so a crawler does not wake a container for every page.
  • the key of your queue. Laravel's sqs connection reads the same AWS_* names as its s3 disk, and at Scaleway the queue and the bucket have keys of their own. The package hands SQS_ACCESS_KEY_ID and SQS_SECRET_ACCESS_KEY to the sqs connection, so QUEUE_CONNECTION=sqs keeps working with two keys instead of one.

That is the whole list. No Dockerfile, no extra package for storage, mail or the queue, nothing to register: Laravel finds the package itself.

The one new file

brewless.yml is the counterpart of vapor.yml. You do not write it by hand:

composer require brewless/laravel
brewless login acme
brewless init --from-vapor

init reads vapor.yml once, asks which application at Brewless this is, adds the environments you pick in the region you choose, and writes the file that says the same wherever Brewless has a setting for it:

brewless.yml
organisation: acme
application: shop
framework: laravel
php: '8.4'

release:
  - 'migrate --force'

environments:
  production:
    kind: production
    region: fr-par
    web:
      memory: 1024
      min: 1
    database: shop-db
    queue: sqs
    storage: shop-files
    domains:
      - shop.example.eu

Keys that do not translate one to one are printed in the terminal, once, with what happened to them; the file itself stays clean. brewless plan then shows what the file asks for next to what stands, and changes nothing. The whole key-by-key table is in Moving from Laravel Vapor, and the earlier post on this move walks through it.

Commit brewless.yml: it holds no secrets. One more file may need a line. Vapor's ignore has no counterpart, because a deploy packs what is committed with git archive: keep a path out of the image with export-ignore in .gitattributes.

What stays exactly as it was

In your application Why it keeps working
Migrations They run as release commands before the release goes live, as Vapor's deploy step ran them
schedule() in your app The worker ticks it every minute; there is no cron to write
Queued jobs QUEUE_CONNECTION=sqs on a queue at Scaleway Queues, which calls the worker as a job arrives
Storage::disk('s3') The same disk, with AWS_* pointing at Scaleway Object Storage
Mail A production environment with a domain gets Scaleway Transactional Email on Laravel's smtp mailer
Sessions and cache Whatever you used on Vapor: the database, a cache, cookies. Not file, as on Lambda
Trusted proxies The package hands the real hostname and address back to Laravel
Your frontend build Brewless runs npm ci && npm run build itself and serves the hashed files from the edge

Only the values of your variables change, and brewless env:pull has written the new ones for you.

Where code does change

Vapor's own helpers. Grep your project for vapor before you start: grep -rni vapor app config resources routes. An upload that goes through Vapor's signed storage URL route, with the laravel-vapor npm package in front of it, is the one that turns up most. Laravel has had its own since 10.x: Storage::disk('s3')->temporaryUploadUrl(...), which works the same way against Scaleway Object Storage.

A MySQL database that becomes PostgreSQL. The serverless database Brewless sets up speaks PostgreSQL, and brewless db:import converts a MySQL source on the way. Your application then has to work on PostgreSQL too, and that is where real work can hide.

Warning

MySQL compares text without regard to case and PostgreSQL does not, so where('email', $email) can find nothing where it found a row before. Raw SQL, GROUP BY with columns that are not grouped, and MySQL's JSON functions behave differently too, and views, triggers and stored routines are not converted at all: make those again in a migration. Import into a test environment first and try the application there.

Rather not? Name a Managed Database for MySQL when you set the environment up. db:import then copies MySQL into MySQL as it is, and nothing in your code changes.

Your own database connection class. An application that registers one with Connection::resolverFor('pgsql', ...) keeps its own class, and then misses the wait that rides out a maintenance restart of a Serverless SQL Database. Add use Brewless\Laravel\Database\RetriesLostConnections; to it; see Databases.

Headers, PHP settings or a step before the server starts. Those live in docker/Caddyfile, docker/php.ini and docker/entrypoint.sh, each replacing a default of the base image as a whole. Most applications commit none of the three. See Server configuration.

The migration, start to end

Steps 4 and 5 are the optional ones: an application without data to carry over skips them.

  1. Add the package and the file. composer require brewless/laravel, then brewless init --from-vapor and brewless plan. Commit both changes.
  2. Set up a test environment if init did not add one: brewless provision test. It makes a database, a queue and a bucket in your own Scaleway project.
  3. Push your variables. vapor env:pull <environment> writes Vapor's own variables, secrets included, as .env.<environment> — which is why the Brewless side gets a file of its own: brewless env:pull test --file=.env.brewless-test. That one holds what Brewless set: the database, the queue, the bucket and a new APP_KEY. Copy the rest over from Vapor's file, keep the DB_*, AWS_*, SQS_*, QUEUE_CONNECTION and FILESYSTEM_DISK lines Brewless wrote, and push with brewless env:push test --file=.env.brewless-test. Then remove both files.
  4. Copy your database into the test environment with brewless db:import, below.
  5. Copy your files into its bucket with brewless storage:import, below.
  6. Deploy and try it. brewless deploy test builds the commit in your own project, runs your migrations and puts the release on the environment's own address. Click through the application there: the DNS of your domain still points at Vapor.
  7. Do the same for production. Push its variables, copy the data, brewless deploy production, and try it on its own address again. Answer no while a deploy offers to add the domains of brewless.yml.
  8. Cut over. Below.
  9. Clean up. Remove laravel/vapor-core and laravel/vapor-cli, the read-only key you made at AWS and the database user you made at Vapor, and take the Vapor environment down when you are sure. Brewless never removes anything there.

Warning

Replace the new APP_KEY with the one from Vapor when your application has encrypted data, encrypted casts or sessions you want to keep: Laravel reads those only with the key they were written with.

Or hand the whole thing to your coding agent

Those nine steps are a good brief for an agent: a handful of commands, a repository to work in, and a few decisions that are yours and nobody else's. The prompt below walks Claude Code, Codex, Cursor, Mistral or whatever you use through the migration, and — the part that matters — tells it to stop and ask you by name whenever a step needs a password, a choice, or a command that changes something outside your repository. It uses your Vapor client for what Vapor can tell it: vapor env:pull for the variables and the bucket's name, vapor database:user for a user to copy the database with. What Vapor keeps to itself, such as the address of your database and a key for your bucket, it asks you for. Copy it, paste it in your project, and answer its questions.

A prompt for your coding agent
Move this Laravel application from Laravel Vapor to Brewless (docs: https://docs.brewless.eu). Work on a branch, commit as you go, and change as little as you can.

How to work with me:
- Ask me, one question at a time, whenever a step needs something only I can give or decide: a password, an access key, a region, which environments to move, which database to use, whether a value is still right. Never invent a value, and never read a secret out of a file to show it back to me.
- Stop and wait for my explicit yes before anything that changes something outside this repository: brewless provision, env:push, deploy, db:import, storage:import, domain:add, and anything at Vapor or AWS. Before you ask, say what the command will do, in which environment, and what it costs.
- Never pass --force or --replace without asking first, and never commit a file that holds secrets.
- When a step needs me somewhere else - a key in the AWS console, a DNS record at my provider, a password in the Vapor dashboard - say exactly what to make, and wait until I tell you it is done.
- Keep a running checklist of the steps below and show me where we are after each one.

The steps:
1. Read vapor.yml, composer.json and package.json. Report what this app uses: environments, database engine, queues, storage, scheduler, cache, domains, and anything Vapor-specific (laravel/vapor-core, laravel/vapor-cli, the laravel-vapor npm package, Vapor's signed storage URL route, Vapor helpers in the code). Ask me about everything you cannot tell from the code.
2. Run composer require brewless/laravel. Leave the Vapor packages where they are: the app keeps deploying to Vapor until its domain moves.
3. Make sure I am signed in with the Brewless client. If a command says otherwise, tell me to run brewless login <organisation> and wait.
4. Run brewless init --from-vapor. Ask me for every choice it makes (which application, which environments, which region, database, queue, bucket) instead of answering for me. Then show me the brewless.yml it wrote and every line it said it could not carry over as it was, and run brewless plan and explain the difference per environment.
5. Variables. First pull both sides into files of their own: vapor env:pull <vapor environment> (writes .env.<environment>, secrets included) and brewless env:pull <environment> --file=.env.brewless-<environment>. Compare them by name and show me the comparison as names only, never values: which names Vapor has that the Brewless file misses, which ones Brewless set itself and we keep as they are (DB_*, AWS_*, SQS_*, QUEUE_CONNECTION, FILESYSTEM_DISK), and which ones only mean something on Vapor and can go. Copy over the ones I confirm. Ask me straight out whether this app has encrypted data, encrypted casts or sessions I want to keep: if so, APP_KEY has to be the one from Vapor's file, not the new one. Push after my yes, then delete both files and make sure neither is committed.
6. Ask me whether to copy the data, and do it into a test environment first.
   - Files: the file you pulled from Vapor names the bucket in AWS_BUCKET, so run brewless storage:import <environment> --from-env=.env.<vapor environment>. It holds no AWS key, because the app on Vapor runs with an IAM role: ask me to make a key that may only read that one bucket, and pass it as SOURCE_AWS_ACCESS_KEY_ID and SOURCE_AWS_SECRET_ACCESS_KEY in the environment of the command, never as an option.
   - Database: that file has no DB_* either, because Vapor fills those in when it deploys. Ask me for the host, port and database name from the Vapor dashboard, and have me run vapor database:user <database> brewless-import to make a user for the move; Vapor shows its password once, and I give it to you as SOURCE_DB_PASSWORD. Then brewless db:import <environment> <postgres|mysql>://brewless-import@<host>/<database>.
   - Run both with --dry-run first and show me what they count before you ask to run them for real.
   - If a MySQL database lands in a PostgreSQL one, say so and list the places in the code that can behave differently: case-insensitive comparisons on text, raw SQL, MySQL JSON functions, and views, triggers or stored routines, which are not converted.
7. After my yes, brewless deploy <test environment>. If the release fails, read brewless logs <environment> --source=release, tell me what you think is wrong and what you want to change, and only then change it.
8. Report what you did, what you checked, what is still open and what is mine to do. Do not touch production, the domains or anything at Vapor unless I ask for it in so many words.

If you are unsure at any point, ask me instead of guessing.

An agent that works in your project reads brewless/laravel as well: from 0.9 the package ships a short guideline and four skills, which Laravel Boost installs for Claude Code, Codex, Cursor and the rest. The agent then knows the commands, and the rules around them, without this prompt spelling them out. See Coding agents.

Optional: your database in one command

brewless db:import copies a PostgreSQL or MySQL database into the database the environment's application signs in to. You say where the data is now and give a user that may read it; where it goes follows from the environment.

brewless db:import test mysql://brewless-import@shop-db.abc123.eu-west-1.rds.amazonaws.com/vapor
  • It runs in your own Scaleway project, as a serverless job with pg_dump, mariadb-dump or pgloader, so the data goes from AWS straight to your database at Scaleway: not through Brewless, not through your laptop, and you can close the lid. Both passwords reach the job in one secret of your own Secret Manager, and that version is deleted when the copy ends, however it ends.
  • The password is never an argument or an option. It comes from SOURCE_DB_PASSWORD, from the .env of --from-env, or from the question in the terminal. Vapor does not put the DB_* values in vapor env:pull: they are on the database's page in the dashboard, or make a user for the move with vapor database:user shop-db brewless-import and remove it afterwards.
  • The destination has to be empty. It counts the tables first and writes nothing when there are any. A new environment's database is empty until its first deploy runs your migrations, so copy before that deploy, or afterwards with --replace.
  • --dry-run copies nothing: it signs in to both sides, counts the tables and the size of the source, and from PostgreSQL to PostgreSQL also tries the source's tables in the destination and rolls them back. Worth running first, because AWS bills the data that leaves it.
  • A private source is not reachable from a job: a private Vapor database, or any Aurora Serverless one. Open a tunnel through the jumpbox of that network and copy from your own machine with --local, which runs the same script in Docker.

brewless db:import-status test shows how it went, and the environment's page in the console has it among its operations. The whole of it is in Moving your database.

Optional: your files in one command

brewless storage:import copies the files of your S3 bucket into the environment's bucket with rclone, in a job of your own project. It removes nothing, at either end, and skips a file that is there already with the same size and time, so you can run it now and once more at the cut-over.

export SOURCE_AWS_ACCESS_KEY_ID=AKIA… SOURCE_AWS_SECRET_ACCESS_KEY=…
brewless storage:import test s3://my-vapor-bucket --source-region=eu-west-1

The bucket's name is under storage in vapor.yml. On Vapor the application runs with an IAM role, so its .env holds no key: make a user at AWS that may only ListBucket and GetObject on this one bucket, and remove it after the move. Your application reads the files through the same s3 disk as before; only the AWS_* variables changed. Move your S3 files from AWS to Brewless has the details.

The cut-over

Your visitors are still at Vapor at this point, and your release at Brewless does what it should on its own address. Then, in one sitting:

vapor down production
brewless db:import production postgres://brewless-import@old-host/shop --replace
brewless storage:import production s3://my-vapor-bucket
brewless deploy production
brewless domain:add production shop.example.eu

vapor down puts the Vapor application in maintenance mode, so no write is lost between the last copy and the switch. Both copies only move what changed since the first run. domain:add makes the DNS record when the zone is in your connected accounts, and otherwise prints the CNAME for you to make at your own provider; the certificate follows once it points at the edge, and adding a domain never overwrites a record somebody else made. Set APP_URL to the domain, which domain:add offers to do, and deploy once more so the containers get it.

From there on visitors arrive at Scaleway through bunny.net, and what you had at AWS is yours to remove.

So, no code changes?

For a PostgreSQL application that does not use Vapor's own upload helper: none at all. The migration is composer require brewless/laravel, a brewless.yml that init writes from your vapor.yml, your variables, and two commands for the data.

With MySQL that becomes PostgreSQL, the conversion itself is still one command — but the application now speaks to another database, and that is worth a round of testing on a test environment before anyone's visitors see it. Keep MySQL on a Managed Database and that one disappears too.

The full key-by-key comparison of vapor.yml and brewless.yml is in the Vapor guide.

Frequently asked questions

Do I have to change my application's code to leave Vapor?

In most cases no. You add the brewless/laravel package, remove Vapor's packages, commit a brewless.yml and push your variables. Code changes only where it calls Vapor's own helpers, such as its signed storage URL route, and where a MySQL database is converted to PostgreSQL.

Which packages does a Laravel app need on Brewless?

One, brewless/laravel. It brings the release, schedule and queue routes the containers answer on, the AWS SDK that Laravel's sqs connection needs, and trust in the edge. Without it the release step of a deploy fails, because the route it calls does not exist.

Can I keep laravel/vapor-core installed while I migrate?

Yes. It is the Lambda runtime and has no part to play in a container at Scaleway, so the two sit next to each other while you still deploy to both. Remove it, and laravel/vapor-cli, once you stop deploying to Vapor.

Do I need a Dockerfile?

No. Brewless builds every release on its own public base image with FrankenPHP. Only when you need other response headers, other PHP settings or a step before the server starts do you commit a docker/Caddyfile, docker/php.ini or docker/entrypoint.sh.

Can I keep MySQL instead of converting to PostgreSQL?

Yes. Name a Managed Database for MySQL when you set the environment up and db:import copies your MySQL database into it as it is, triggers and routines included. The serverless database Brewless sets up by default speaks PostgreSQL, and a MySQL source is then converted on the way.

Can my coding agent do the migration for me?

Most of it. The prompt in this post walks Claude Code, Codex, Cursor or any other agent through the whole move, and tells it to stop and ask you whenever a step needs a password, a choice or a command that changes something outside the repository. The brewless/laravel package also ships skills that teach an agent the commands and their rules.

How long is my site unreachable during the move?

As long as your DNS takes to point at the edge. Visitors keep arriving at Vapor until then; you put the Vapor app in maintenance mode only for the last copy of the database and the files, which copies just what changed since the first one.

All posts Read this post as markdown

Try it on your own app

Brewless deploys your Laravel app to Scaleway and bunny.net, in your own accounts. We open access to Laravel teams in small batches.