DEV Community

Cover image for Just Ship the Damn EXE: Small Organizations Shouldn't Need a DevOps Team to Run Your App
Ryo Suwito
Ryo Suwito

Posted on

Just Ship the Damn EXE: Small Organizations Shouldn't Need a DevOps Team to Run Your App

How I bundled SLiMS, PHP, and MariaDB into a double-clickable Windows application for a school librarian.


So, you sell people peace of mind.

Then you leave them with a permanent headache: keep paying your cloud-powered subscription just to keep the application—and sometimes their own data—available.

Fine. Maybe you sell the software for a one-time fee instead.

But now the customer needs to hire IT support to install Linux packages, configure MySQL, manage Docker containers, open ports, create database users, and figure out why the service did not restart after a power outage.

I call bullshit.

If your “independent alternative” still forces a small organization to depend on recurring payments or permanent technical support, you are not necessarily better than the corporations you claim to be fighting.

Apps used to work like this:

  1. Download the installer.
  2. Double-click it.
  3. Use the application.

What happened to that?

Even if you send technical staff for the first installation, wouldn't it be better if the user could reinstall the application themselves? Wouldn't it be nice if recovering from a dead PC meant restoring a backup instead of performing acrobatics with APT commands, Docker Compose files, database credentials, and half-remembered Stack Overflow answers?

This article is about turning a traditional PHP and MariaDB web application into something a school librarian can install by double-clicking one .exe.

Not a SaaS platform.

Not a Docker deployment.

Not a Kubernetes cluster for managing 3,000 books.

A Windows application.

The actual problem

My wife was going to manage a junior high school library.

We needed a library management system, and building one from scratch would have been silly. There are already mature open-source applications for this, including SLiMS, or Senayan Library Management System.

SLiMS already provides the important things:

  • Bibliographic cataloging
  • Membership management
  • Circulation and loan records
  • Barcodes
  • Reports
  • Administration
  • Backup-related tools
  • An established community and years of development

The application itself was not the problem.

Delivery was.

SLiMS is a PHP application backed by MySQL or MariaDB. A normal deployment expects some combination of PHP, a web server, a database server, and somebody who understands how to keep them alive.

Docker makes this significantly easier for developers.

A school librarian is not a developer.

Telling her to install Docker Desktop, understand containers, and remember that closing the wrong thing may stop the library system is not a solution. It is exporting my technical convenience to the user.

The goal became simple:

Can we make a PHP and MariaDB application behave like an ordinary Windows desktop application?

The desired experience

The entire installation process should be:

  1. Download Perpustakaan-SMP-Setup.exe.
  2. Double-click it.
  3. Confirm the installation.
  4. Enter the library name and administrator credentials.
  5. Start managing books.

On subsequent uses:

  1. Double-click the Desktop shortcut.
  2. Wait for the startup screen.
  3. The application opens in the browser.

The user should never need to know that PHP and MariaDB are involved.

Those are implementation details. Implementation details belong to the developer.

The architecture

I did not compile PHP and MariaDB into one magical binary. Instead, I created a native launcher and bundled portable Windows distributions of the required components.

The installed application looks roughly like this:

PerpustakaanSMP.exe
runtime/
├── php/
│   ├── php.exe
│   ├── php.ini
│   └── ext/
├── mariadb/
│   └── bin/
│       ├── mariadbd.exe
│       ├── mariadb.exe
│       └── mariadb-admin.exe
└── router.php
app-template/
└── SLiMS source code
Enter fullscreen mode Exit fullscreen mode

PerpustakaanSMP.exe is a small native launcher written in Go.

Its job is to:

  • Initialize the application on first run
  • Generate local database credentials
  • Initialize MariaDB
  • Create the SLiMS database and database user
  • Generate the SLiMS database configuration
  • Start MariaDB
  • Start PHP's local web server
  • Display startup progress
  • Open the browser
  • Monitor both child processes
  • Shut everything down safely
  • Create and restore backups

From the user's perspective, it is one application.

From the operating system's perspective, it is a supervised local PHP stack.

No administrator privileges and no Windows service

I deliberately avoided installing MariaDB as a global Windows service.

Services create another collection of problems:

  • They may require administrator privileges.
  • They keep running when the application is not being used.
  • They introduce service names and global configuration.
  • They can conflict with existing MySQL installations.
  • Troubleshooting them often requires administrative tools.

Instead, the launcher starts its private MariaDB process only while the library application is running.

MariaDB listens on:

127.0.0.1:3307
Enter fullscreen mode Exit fullscreen mode

PHP listens on:

127.0.0.1:8123
Enter fullscreen mode Exit fullscreen mode

Both are bound to the loopback interface. They are not exposed to the school network or the internet.

This version is intended for a single library computer. A multi-computer deployment would require a different security and networking model.

Separating the application from its data

A reinstallation must never destroy the library database.

Program files and user data are therefore stored separately.

Program files:

%LOCALAPPDATA%\Programs\PerpustakaanSMP\

Persistent data:

%LOCALAPPDATA%\PerpustakaanSMP\
├── app\
├── database\
├── logs\
├── sessions\
├── backups\
└── secrets.conf
Enter fullscreen mode Exit fullscreen mode

The installer is allowed to replace:

  • The launcher
  • PHP
  • MariaDB binaries
  • The PHP configuration
  • The router
  • The bundled SLiMS application template

It does not replace the MariaDB data directory.

Uploaded files, images, repositories, and application configuration are preserved during application updates.

This means the user can run a newer installer over the existing installation without recreating the administrator or losing catalog data.

First-run provisioning

On the first launch, the native launcher performs the work normally left to a system administrator.

It generates random credentials for:

  • The MariaDB root account
  • The dedicated SLiMS database account

It then initializes the local MariaDB data directory and creates:

CREATE DATABASE slims
    CHARACTER SET utf8mb4
    COLLATE utf8mb4_unicode_ci;
Enter fullscreen mode Exit fullscreen mode

A dedicated application account is granted access only to that database.

The generated configuration is equivalent to:

Host:     127.0.0.1
Port:     3307
Database: slims
Username: slimsapp
Password: randomly generated
Enter fullscreen mode Exit fullscreen mode

The librarian never needs to see or manage these credentials.

They are generated because the software needs them, not because the user should become a part-time database administrator.

Making a PHP application behave correctly without Apache

SLiMS normally runs behind Apache or another traditional web server. To reduce the bundle's complexity, I used PHP's built-in server:

php.exe `
  -S 127.0.0.1:8123 `
  -t <slims-directory> `
  router.php
Enter fullscreen mode Exit fullscreen mode

The custom router handles:

  • PHP entry points
  • Directory index files
  • Static assets
  • SLiMS routes
  • A private health-check endpoint

This sounds straightforward. It was also the source of some wonderfully deceptive bugs.

Bug one: the working directory

The first version could display the SLiMS installer. After creating the administrator, however, the admin page crashed.

The log revealed:

Failed opening required '../sysconfig.inc.php'
Enter fullscreen mode Exit fullscreen mode

The file existed.

The problem was the current working directory.

Some older PHP applications use relative includes based on the assumption that Apache executes a script from its directory. My router was executing the requested file while the working directory still pointed to the application root.

The fix was to temporarily change the directory before requiring the script:

$previousDirectory = getcwd();

chdir(dirname($script));

try {
    require basename($script);
} finally {
    chdir($previousDirectory);
}
Enter fullscreen mode Exit fullscreen mode

The missing file error disappeared.

Then we got:

Invalid access module
Enter fullscreen mode Exit fullscreen mode

Progress!

Bug two: PHP include scope

My first router implementation placed the require inside a helper function:

function runPhpScript(string $script): bool
{
    require $script;
    return true;
}
Enter fullscreen mode Exit fullscreen mode

That changes the scope in which the included file executes.

SLiMS initializes configuration such as $sysconf during its bootstrap. Some older modules later access it using:

global $sysconf;
Enter fullscreen mode Exit fullscreen mode

Because the bootstrap had run inside my helper function, $sysconf was local to that function rather than part of the global scope.

The files were now found, but the application configuration effectively disappeared when security modules tried to access it.

The correct router executes require at the router's top-level scope:

$previousDirectory = getcwd();

$_SERVER['SCRIPT_FILENAME'] = $script;
chdir(dirname($script));

try {
    require basename($script);
} finally {
    chdir($previousDirectory);
}

return true;
Enter fullscreen mode Exit fullscreen mode

Compatibility work is often like this. The hard part is not starting PHP. The hard part is reproducing environmental assumptions accumulated over many years.

A port being open does not mean the application is healthy

Initially, the launcher checked whether port 8123 was open.

That was insufficient.

A process can own the port while the application behind it is broken. Another unrelated process could also be using the same port.

I added a private endpoint:

/__perpustakaan_health
Enter fullscreen mode Exit fullscreen mode

It returns a fixed value:

perpustakaan-smp-ok
Enter fullscreen mode Exit fullscreen mode

The launcher considers SLiMS ready only if it receives:

  • HTTP status 200
  • The expected response body
  • Within a short timeout

The browser is opened only after this check succeeds.

Startup feedback matters

Starting PHP is quick. Starting a database, initializing its system tables, creating accounts, and provisioning an application is not always quick.

The first build opened the browser only after everything was ready.

Technically, that worked.

From the user's perspective, double-clicking the shortcut appeared to do nothing.

Even I could not immediately tell whether the application was starting.

The launcher now starts a temporary progress page first:

Checking application components…
Preparing SLiMS files…
Preparing the local database…
Starting MariaDB…
Starting SLiMS…
Enter fullscreen mode Exit fullscreen mode

When the health check passes, that page redirects to the actual application.

A loading screen is not merely decoration. It is confirmation that the user's action had an effect.

Dealing with stale and duplicate processes

Another early build produced contradictory messages:

The application is not running.
Enter fullscreen mode Exit fullscreen mode

Followed by:

An old process is still running.
Enter fullscreen mode Exit fullscreen mode

Both could technically be true according to different checks.

One check inspected the HTTP server. Another found an old PID or database process. Neither understood the application as one supervised unit.

The revised launcher records the PIDs of:

  • The launcher
  • PHP
  • MariaDB

It also verifies that a recorded PID actually belongs to the expected executable path.

This matters because operating systems reuse process IDs. Blindly killing a stale PID could terminate an unrelated process.

On shutdown, the launcher:

  1. Signals its existing controller process.
  2. Stops accepting application requests.
  3. Uses mariadb-admin shutdown for a clean database shutdown.
  4. Waits for the child processes to exit.
  5. Force-terminates only confirmed application-owned processes when necessary.
  6. Removes stale PID files.

Double-clicking the shortcut twice no longer creates two database servers. The second launcher waits for the first one and displays startup progress.

Packaging everything into one installer

The staged application is compressed into a ZIP archive.

A second Go program embeds that archive directly:

//go:embed payload.zip
var payload []byte
Enter fullscreen mode Exit fullscreen mode

That program becomes:

Perpustakaan-SMP-Setup.exe
Enter fullscreen mode Exit fullscreen mode

The installer:

  • Stops an existing installation safely
  • Extracts the embedded payload
  • Creates Desktop and Start Menu shortcuts
  • Registers the backup file association
  • Starts the application

The distributed artifact is one approximately 180 MB executable.

After installation, its components are extracted normally. This is conceptually similar to how Electron applications ship Chromium or how games bundle their required runtimes.

“One executable” is a delivery experience, not necessarily a claim that every dependency was statically linked into one process.

Backup must also belong to the user

Local-first software should not replace subscription dependency with “pray that the hard disk never dies.”

The launcher creates a portable backup containing:

  • A database dump
  • Uploaded files
  • Images
  • Repository contents
  • Application configuration
  • Backup metadata

The backup uses a custom .slimsbackup extension registered with Windows. Restore can therefore be initiated by double-clicking the backup file.

The default backup destination is the user's Documents directory, where it can be copied to a flash drive, external disk, NAS, or whichever storage the school actually controls.

Local-first does not mean backup-free.

It means the user decides where the backup lives.

What I learned

If you want to turn a server-oriented application into a user-installable local product:

  1. Treat PHP, the database, and the web application as private implementation details.
  2. Keep program files separate from persistent user data.
  3. Bind local services to loopback unless network access is explicitly required.
  4. Generate internal credentials automatically.
  5. Supervise the complete process lifecycle.
  6. Validate process ownership before terminating anything.
  7. Use real health checks, not port checks.
  8. Show progress immediately after the user clicks.
  9. Expect older applications to depend on working directories and global scope.
  10. Make backup and restore part of the product, not an appendix in the documentation.
  11. Test upgrade and failure paths, not just fresh installations.
  12. Respect every upstream license in the bundle.

The result may not be architecturally fashionable.

That is fine.

The librarian does not care whether the application uses containers, microservices, serverless functions, or an enterprise-grade service mesh.

She wants to lend books.

And honestly, that is a much better product requirement than “must look impressive in an architecture diagram.”

Top comments (0)