I run SeAT, the EVE Online corporation management tool, for our corp in Docker on a Proxmox LXC, managed with Dockge. Getting it from SeAT 4 to SeAT 5 and then keeping it healthy turned into a long series of problems. This post collects all of them in one place, with the fixes that actually worked, so the next person (or future me) doesn’t have to dig through logs again.
The stack is a split compose setup, so every command needs the full -f chain:
|
1 2 |
cd /opt/stacks/seat-exop docker compose -f docker-compose.yml -f docker-compose.mariadb.yml -f docker-compose.proxy.yml up -d |
Services are front, worker, scheduler, cache (Redis) and mariadb.
1. “invalid_scope” when linking characters
Adding or linking a character suddenly failed with:
|
1 |
{"error":"invalid_scope","error_description":"The requested 'esi-bookmarks.read_character_bookmarks.v1' scope is not valid."} |
CCP removed the bookmarks scope from ESI. Removing it from the SSO application on the developer portal is not enough: SeAT keeps its own list of requested scopes in the database and still asks for it, so EVE SSO rejects the whole request.
Fix: delete the scope from SeAT’s scope table and clear the cache.
|
1 2 3 |
-- in the mariadb container SELECT * FROM seat_scope_groups WHERE scope LIKE '%bookmark%'; DELETE FROM seat_scope_groups WHERE scope = 'esi-bookmarks.read_character_bookmarks.v1'; |
|
1 2 |
docker compose ... exec front php artisan cache:clear docker compose ... exec worker php artisan cache:clear |
Depending on the version, the table may be called required_scopes instead. Also check your SSO profile in Settings → SSO.
2. The SeAT 4 → 5 upgrade
This took several rollbacks. It wasn’t one problem but a chain of them, each hidden behind the previous one.
2.1 Plugins without a SeAT 5 release
Composer failed because denngarr/seat-fitting only supports eveseat/services ^4.1. The same was true for seat-srp, an alliance structure plugin and a calendar plugin. Many community plugins were never ported.
Fix: trim SEAT_PLUGINS in .env down to plugins with a v5 release, e.g.:
|
1 |
SEAT_PLUGINS=warlof/seat-connector,warlof/seat-discord-connector |
2.2 “Invalid URI: Host is malformed”
The composer post-update hook (artisan vendor:publish) died with this. Laravel couldn’t parse APP_URL.
Fix: APP_URL must be a full URL including the scheme: APP_URL=https://seat.example.com. Not empty, not without https://.
2.3 APP_KEY=insecure
With debug mode on, the real problem showed up: the new .env still had the placeholder APP_KEY=insecure.
Fix: copy the old APP_KEY from the SeAT 4 .env. Do not generate a new one: all refresh tokens and secrets in the database are encrypted with the old key and become unreadable otherwise. APP_CIPHER=AES-256-CBC isn’t needed anymore, it’s the default.
2.4 SeAT 5 started… with an empty database
Finally it ran, but none of the old data was there. The MariaDB log said it all:
|
1 2 |
[Entrypoint]: Initializing database files [Entrypoint]: Creating database seat |
The new stack had created a fresh volume with a different name instead of using the SeAT 4 data.
Fix: find the old data (docker volume ls | grep seat) and point the new MariaDB service at it. I now use a bind mount at ./mariadb-data, which makes it obvious where the data lives. When MariaDB says “upgrade not required” on startup, you’re looking at your old database.
2.5 Migration collisions
With the old data back, front failed on migrations like 2023_02_23_174650_add_market_orders_data: the table market_orders already existed, but the migration wasn’t recorded in the migrations table.
Fix: after verifying the table really is there and complete, record the migration as applied:
|
1 2 |
docker compose ... exec worker php /var/www/seat/artisan tinker --execute="DB::table('migrations')->insert(['migration' => '2023_02_23_174650_add_market_orders_data', 'batch' => 99]);" docker compose ... exec worker php /var/www/seat/artisan migrate:status | grep Pending |
Run migrate:status again and repeat for any other pending migration whose table already exists.
2.6 “Invalid CRON field value 0/30”
A leftover SeAT 4 entry in the schedules table used Quartz-style syntax. Non-blocking, but noisy. Edit or delete it in Settings → Schedule.
3. Front crash loop after installing plugins
Later I installed a few new plugins (seat-inventory, corp-mining-tax and others) and front went into a loop with exited with code 1. The log showed duplicate table and column errors during migrate.
Two root causes:
- The migrations table was still out of sync from the upgrade. Tables from earlier installs of these plugins existed, but their migration records didn’t.
- A zombie SeAT 4 stack was still running.
seat-web,seat-worker,seat-cronand an old Redis had never been removed after the upgrade. Checkdocker psfor anything still oneveseat/seat:4.
Fix:
- Stop and remove the old SeAT 4 containers, so only one stack writes to the database.
- Back up the database. Mine was 15 GB; do it anyway.
- Run
php artisan migrate --forcein a loop. Whenever it fails with “table already exists”, “duplicate column” or “duplicate key” (SQLSTATE 1050, 1060, 1061), record that migration as applied like in 2.5 and run again. On any other error, stop and look manually. - For migrations that don’t only create schema (one plugin’s
Categoriesmigration also inserts data), check the table contents before faking it.
After the loop ran clean, front came up and SeAT reported ready.
Side note: the corp-mining-tax main page then returned a 500 for my admin account only. The log pointed to CharacterHelper::getLinkedCharacters(0): the built-in admin user has no linked character, and the plugin doesn’t handle that. Normal users see the page fine. That’s a plugin bug, not a config problem.
4. Wallet data not updating
Wallet journal and transactions went stale while contracts and killmails kept updating. What I ruled out:
- A few characters were missing wallet scopes on their tokens, but most had them.
- No failed jobs related to wallets, and the update buckets were balanced.
- The wallet jobs do run (visible in Horizon).
The difference: contracts and killmails have their own scheduled commands (esi:update:contracts, esi:update:killmails), while wallets only update through seat:buckets:update. The leading theory is ESI ETag/304 caching: the jobs run, ESI answers “not modified”, and nothing new gets written. I haven’t fully confirmed this one yet.
5. SDE stuck on an old version
The SDE badge in the UI kept showing old/sde-20250707-TRANQUILITY. Copying a newer Fuzzwork SQLite dump into storage/sde (via docker compose cp, since plain docker cp needs the full container name) didn’t help: SeAT 5 can’t import that format.
It turns out SeAT 5 has two separate SDE pipelines:
- Legacy (Fuzzwork):
php artisan eve:update:sdefills the old tables (invTypes,mapDenormalize, …). Fuzzwork itself is frozen at that 2025 build, so this will never get newer. The UI badge only reflects this pipeline. - Modern (CCP JSONL):
php artisan db:seed --class="Seat\\Eveapi\\Database\\Seeders\\CcpSdeSeeder"pulls CCP’s current SDE for inventory and dogma data. It failed once with an S3AccessDeniederror page saved as a fake zip, and worked on retry.
Fix: run both. Inventory/dogma is current via JSONL; map/universe data still comes from the frozen Fuzzwork path until eveapi ships a JSONL map seeder. Ignore the badge.
6. Taming the error flood
At one point the Laravel log grew by about 600 MB a day, with thousands of errors. I worked through them one category at a time.
6.1 Discord 403s (more than half of all errors)
The SeAT bot’s integration role sat below roles it was supposed to manage. Discord only lets a bot change roles below its own highest role. Fix: move the bot role above every role seat-connector manages.
6.2 Webhook 429s and “getJobId() on null”
A debug notification group subscribed to every notification type and sent all of it to a single webhook. That hit the rate limit and caused a cascade of follow-up errors. Fix: delete that notification subscription.
6.3 Orphaned scheduled commands
calendar:remind, make:notification and strict:audit were still in the schedule from plugins that no longer exist. Fix: remove them in Settings → Schedule.
6.4 PHP memory exhaustion
There is no PHP_MEMORY_LIMIT environment variable in the SeAT 5 image. Fix: create php-memory-limit.ini next to the compose files:
|
1 |
memory_limit = 512M |
and mount it into front, worker and scheduler:
|
1 2 |
volumes: - ./php-memory-limit.ini:/usr/local/etc/php/conf.d/php-memory-limit.ini:ro |
6.5 Citadel retry storm
About 12,000 errors from structure jobs hitting 403s on citadels the characters can’t access, retried over and over. Fix: php artisan queue:flush cleared the backlog, and it didn’t come back.
Result: from up to 12,000 errors a day down to normal ESI background noise (expired tokens and the like).
One quirk worth knowing: seat-connector:sync:sets and seat-connector:apply:policies work from the CLI in the worker container, but triggering them from the web UI says “command does not exist”.
7. seat-connector fighting Discord over roles
7.1 Manually granted roles disappear after a few minutes
Some members got a role by hand in Discord, and the SeAT bot removed it again about five minutes later. seat-connector does a full reconciliation: any Discord role it can manage (i.e. below the bot) but that no filter grants gets removed from everyone. Switching the security level from Strict to Normal doesn’t change that.
Moving the role above the bot works, but it’s fragile: role positions can shift, and then the loop starts again.
7.2 Members losing a role they should have
Worse, a couple of members kept losing their corp role even though the corporation filter was correct and worked for everyone else. The cause was a different role they held, positioned above the bot. The connector tried to remove that role, got a 403, and aborted the whole reconcile for that member, before it ever got to adding the corp role back. Hundreds of failed DELETE calls, zero add calls.
Durable fix for both: give every Discord role the connector can see a filter that grants it. For a manually curated role, create a SeAT Squad with those members and add a Squad Filter in the connector mapping it to the Discord role. For a leadership role, a SeAT Role with a Role Filter works the same way. That role doesn’t need any permissions, it only drives the mapping. Once the filter existed, the reconcile went through and the missing roles came back immediately.
7.3 One user doesn’t get a role despite the right title
Two of three new directors got their Discord role, one didn’t. He insisted his Discord was linked. It wasn’t. Check Connector → User Mapping instead of trusting what users tell you.
Takeaways
- Keep the old APP_KEY on any SeAT upgrade.
- Know where your database lives before you start the new stack, and back it up before touching migrations.
- Remove the old stack after an upgrade. A forgotten SeAT 4 worker writing to the same database is a recipe for weirdness.
- Read the Laravel log by category, fix the biggest one, repeat. Most of my error volume came from three or four causes.
- seat-connector manages everything below the bot. Every role in that range needs a filter, or it will be stripped, and a single failing role can block the whole sync for a member.