Migrating from MyInvoice to MyÚčto: Three Gotchas I Ran Into

I've been using MyInvoice for invoicing for quite some time. Recently it evolved into MyÚčto—a new project by the same developer (Radek Hulán). It shares the same foundation but adds a full double-entry accounting layer on top. The invoicing features remain free forever, while the accounting module is a paid upgrade.

Since all future development is now focused on MyÚčto, migrating was an easy decision.

On paper, the process looks straightforward: deploy a new instance alongside the old one, run the built-in migration script, switch your Nginx configuration, and you're done.

In reality, I ran into three unexpected issues that cost me far more time than the migration itself. Chances are they'll affect anyone performing the same migration.

Preparation — Nothing Unusual

  • Back up your database (mariadb-dump --all-databases) and your application data volume (PDF invoices, logos, attachments) using docker run with bind mounts.
  • Deploy MyÚčto as a completely separate Docker Compose stack instead of upgrading the existing installation in place. Use a new internal port and a dedicated database container.
  • Connect the MyÚčto application container to the Docker network of the old MyInvoice installation so the migration script can access the source database.

That's where the fun began.

Gotcha #1: Docker DNS Alias Collision

After starting the application I immediately got:

Access denied for user 'myucto'@'IP' (using password: YES)

The password was correct.

The explanation turned out to be surprisingly simple.

Both Docker Compose projects contained a database service named db, which means Docker automatically created the same DNS alias (db) on both networks. Once I connected the application container to both networks simultaneously, Docker's embedded DNS resolved db to the wrong database.

The application was therefore trying to authenticate against the MyInvoice database using MyÚčto credentials—which obviously failed.

Solution: don't use the generic service alias db. Instead, use the actual container name, which is globally unique across Docker:

db.host => 'myucto-db-1'   // instead of 'db'

Gotcha #2: OPcache Refused to See Configuration Changes

Even after fixing the configuration, the application continued reporting the exact same authentication error.

The configuration file on disk was correct.

A simple include in PHP returned the updated value.

Yet the PDO connection string shown in the logs still contained the old hostname.

It felt as though the application and the filesystem lived in different realities.

The culprit was:

opcache.validate_timestamps = Off

With timestamp validation disabled, PHP OPcache never checks whether a source file has changed. It simply continues executing the cached compiled version.

Even docker compose restart wasn't enough, because the PHP process inside the container remained alive together with its in-memory OPcache.

Solution: after every configuration change, recreate the application container instead of merely restarting it:

docker compose up -d --force-recreate app

--force-recreate destroys the existing container and starts a brand-new PHP process with an empty OPcache.

Gotcha #3: Migrating Without the Original Pepper Breaks Authentication

Once the application finally started, the migration completed successfully.

The first run migrated 81 out of 86 tables. The remaining ones succeeded after removing --no-truncate, which conflicted with seeded data.

Then I tried to log in.

Invalid credentials.

The password hashes themselves had migrated correctly.

The problem was the pepper.

Besides bcrypt, the application uses an additional server-side pepper during password verification. The migration copies password hashes exactly as they exist in the database—but my fresh MyÚčto installation had generated a new pepper, not the original one used by MyInvoice.

The same applies to secret_encryption_key, which is required to decrypt TOTP secrets used for two-factor authentication.

Solution: copy both values from the original cfg.php file:

  • pepper
  • secret_encryption_key

These must be identical to the source installation. Migrating the database alone isn't enough.

Final Thoughts

If I had to give someone a checklist before starting the migration, it would be this:

  1. Back up both the database and the storage volume before touching anything.
  2. Deploy MyÚčto as a separate instance rather than upgrading the existing installation.
  3. Copy pepper and secret_encryption_key exactly as they are from the original installation.
  4. If both installations share Docker networks during migration, connect to databases using container names rather than service aliases to avoid DNS ambiguity.
  5. After every configuration change, recreate the application container using --force-recreate instead of simply restarting it.
  6. Run the migration without --no-truncate on a freshly created empty database so the migration script can clean up seeded tables automatically.
  7. Don't forget the storage/ directory—database migration does not include PDF invoices, logos, or uploaded attachments.

The migration itself completed without losing a single row of data.

Almost all of my time was spent debugging the issues above, none of which were caused by the migration script itself. They were simply general Docker and PHP pitfalls that are easy to run into when connecting two Compose stacks while modifying configuration on the fly.

01_dashboard-2.webp