Skip to content

Running Docker containers from Wails (Mac App)

Published: at 12:00 PM

I recently worked on a macOS desktop app using Wails that needed Docker containers under the hood, built for internal company use only. I didn’t want users to be bothered with the whole Docker and container lifecycle, so the solution I came up with was to bundle the required Docker images as .tar files right inside the macOS app package. From there, I used Go’s exec package to spin up and manage the containers programmatically, so the app could quietly take care of the Docker lifecycle.

In general, it works like this:

Step 1: Accessing bundled resources

First, locate the Resources folder within the app bundle:

func getResourcePath() (string, error) {
    exePath, err := os.Executable()
    if err != nil {
        return "", err
    }

    exeDir := filepath.Dir(exePath)
    resourcePath := filepath.Join(exeDir, "..", "Resources")
    absPath, err := filepath.Abs(resourcePath)
    if err != nil {
        return "", err
    }
    return absPath, nil
}

This navigates from the executable location up to the Resources directory. Here’s the app bundle structure:

MyApp.app/
├── Contents/
│   ├── MacOS/
│   │   └── MyApp (executable)
│   ├── Resources/
│   │   └── docker-image.tar
│   └── Info.plist

Step 2: Managing docker image

The LoadImage() method checks if the image already exists and loads it only when necessary:

func (s *Scanner) LoadImage() error {
    cmd := exec.Command(s.dockerPath, "images", "-q", s.ImageName)
    output, err := cmd.Output()
    if err != nil {
        return fmt.Errorf("failed to check existing images: %w", err)
    }
    if len(output) > 0 {
        // Image already exists, skip loading
        return nil
    }

    cmd = exec.Command(s.dockerPath, "load", "-i", s.TarPath)
    if err := cmd.Run(); err != nil {
        return fmt.Errorf("failed to load docker image: %w", err)
    }
    log.Println("Docker image loaded successfully")
    return nil
}

Step 3: Container lifecycle management

Starting and stopping containers is straightforward, but it can be improved (e.g. to handle port conflicts)

func (s *Scanner) StartContainer() error {
    cmd := exec.Command(s.dockerPath, "run", "-d", "--rm",
        "-p", fmt.Sprintf("%d:3000", s.Port),
        "--name", "app",
        s.ImageName,
    )
    if err := cmd.Run(); err != nil {
        return fmt.Errorf("failed to start container: %w", err)
    }
    return nil
}

func (s *Scanner) StopContainer() error {
    cmd := exec.Command(s.dockerPath, "stop", "app")
    if err := cmd.Run(); err != nil {
        return fmt.Errorf("failed to stop container: %w", err)
    }
    return nil
}

The overall process

What works well:

The trade-offs: