Building Cross-Platform System Services in Go: A Step-by-Step Guide
What Are System Services?
System Services are lightweight programs that operate in the background without a graphical user interface . They start automatically during system boot and run independently.Their lifecycle, which includes operations like start, stop, and restart are managed by Service Control Manager on Windows, systemd on Linux(in most of the destro) and launchd on macOS.
Unlike standard applications, services are designed for continuous operation and are essential for tasks such as monitoring, logging, and other background processes. On linux, these services are generally referred to as daemons, while on macOS, they known as Launch Agents or Daemons.
Why Go for Building System Services**?**
Creating cross-platform system services demands a language that balances efficiency, usability, and reliability. Go excels in this regard for several reasons:
Concurrency and Performance: Go’s goroutines make it easy to run multiple tasks at once, improving efficiency and speed on different platforms. Coupled with a robust standard library, this minimizes external dependencies and enhances cross-platform compatibility.
Memory Management and Stability: Go’s garbage collection prevents memory leaks, keeping systems stable. Its clear error handling also makes it easier to debug complex services.
Simplicity and Maintainability: Go’s clear syntax simplifies writing and maintaining services . Its capability to produce statically linked binaries results in single executable files that include all necessary dependencies, eliminating the need for separate runtime environments.
Cross-Compilation and Flexibility: Go’s support for cross-compilation allows building executables for various operating systems from a single codebase. With CGO, Go can interact with low-level system APIs, such as Win32 and Objective-C, providing developers the flexibility to leverage native features.
Writing Services in Go
This code walk through assumes that GO is installed on your machine and you have a basic knowledge on GO’s syntax, if not i would highly recommend you to Take A Tour .
Project Overview
go-service/
├── Makefile # Build and installation automation
├── cmd/
│ └── service/
│ └── main.go # Main entry point with CLI flags and command handling
├── internal/
│ ├── service/
│ │ └── service.go # Core service implementation
│ └── platform/ # Platform-specific implementations
│ ├── config.go # Configuration constants
│ ├── service.go # Cross-platform service interface
│ ├── windows.go # Windows-specific service management
│ ├── linux.go # Linux-specific systemd service management
│ └── darwin.go # macOS-specific launchd service management
└── go.mod # Go module definition
Step 1: Define Configurations
Initialize the Go module:
go mod init go-service
Define configuration constants in config.go
within the internal/platform
directory. This file centralizes all configurable values, making it easy to adjust settings.
File: internal/platform/config.go
package platform
import (
"fmt"
"io"
"os"
"path/filepath"
"runtime"
)
// Update these constants with your own service configuration details.
// Replace the service name, display name, and description as needed.
// LogFileName is optional and is used to demonstrate an example service core logic
// that appends text to a file periodically.
const (
ServiceName = "go-service"
ServiceDisplay = "Go Service"
ServiceDesc = "A service that appends 'Hello World' to a file every 5 minutes."
LogFileName = "go-service-log.txt"
)
func GetInstallDir() string {
switch runtime.GOOS {
case "darwin":
return "/usr/local/opt/go-service"
case "linux":
return "/opt/go-service"
case "windows":
return filepath.Join(os.Getenv("ProgramData"), ServiceName)
default:
return ""
}
}
func copyFile(src, dst string) error {
source, err := os.Open(src)
if err != nil {
return fmt.Errorf("failed to open source file: %w", err)
}
defer source.Close()
destination, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
return fmt.Errorf("failed to create destination file: %w", err)
}
defer destination.Close()
_, err = io.Copy(destination, source)
return err
}
Key features:
Service Constants which will be used during Platform-Specific Service Configurations.
GetInstallDir() provides appropriate service installation and log file paths for each OS.
copyFile() used during service installation to copy the executable to our specific path provided by GetInstallDir().
Step 2: Defining Core Service Logic
In the internal/service
, implement the core functionality for your service.The core service implementation handles the main functionality of our service.
In this example, the service appends "Hello World" to a file in the user's home directory every 5 minutes.
File: internal/service/service.go
Service Structure
type Service struct {
logFile string
stop chan struct{}
wg sync.WaitGroup
started bool
mu sync.Mutex
}
Service Creation
func New() (*Service, error) {
installDir := platform.GetInstallDir()
if installDir == "" {
return nil, fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
}
logFile := filepath.Join(installDir, "logs", platform.LogFileName)
return &Service{
logFile: logFile,
stop: make(chan struct{}),
}, nil
}
Service Lifecycle
The service implements Start and Stop methods for lifecycle management:
// Start the service
func (s *Service) Start(ctx context.Context) error {
s.mu.Lock()
if s.started {
s.mu.Unlock()
return fmt.Errorf("service already started")
}
s.started = true
s.mu.Unlock()
if err := os.MkdirAll(filepath.Dir(s.logFile), 0755); err != nil {
return fmt.Errorf("failed to create log directory: %w", err)
}
s.wg.Add(1)
go s.run(ctx)
return nil
}
// Stop the service gracefully
func (s *Service) Stop() error {
s.mu.Lock()
if !s.started {
s.mu.Unlock()
return fmt.Errorf("service not started")
}
s.mu.Unlock()
close(s.stop)
s.wg.Wait()
s.mu.Lock()
s.started = false
s.mu.Unlock()
return nil
}
Main Service Loop
func (s *Service) run(ctx context.Context) {
defer s.wg.Done()
log.Printf("Service started, logging to: %s\n", s.logFile)
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
if err := s.writeLog(); err != nil {
log.Printf("Error writing initial log: %v\n", err)
}
for {
select {
case <-ctx.Done():
log.Println("Service stopping due to context cancellation")
return
case <-s.stop:
log.Println("Service stopping due to stop signal")
return
case <-ticker.C:
if err := s.writeLog(); err != nil {
log.Printf("Error writing log: %v\n", err)
}
}
}
}
The run method handles the core service logic:
Key features:
Interval-based execution using ticker
Context cancellation support
Graceful shutdown handling
Error logging
Log Writing
The service appends “Hello World” with a timestamp every 5 minutes
func (s *Service) writeLog() error {
f, err := os.OpenFile(s.logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("failed to open log file: %w", err)
}
defer f.Close()
_, err = f.WriteString(fmt.Sprintf("[%s] Hello World\n", time.Now().Format(time.RFC3339)))
if err != nil {
return fmt.Errorf("failed to write to log file: %w", err)
}
return nil
}
Step 3: Creating Platform-Specific Service Configurations
The internal/platform
directory contains platform-specific configurations to install, uninstall, and manage the service.
macOS (darwin.go)
In darwin.go
, define macOS-specific logic for creating a .plist
file, which handles service installation and uninstallation using launchctl
.
File:internal/platform/darwin.go
package platform
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
type darwinService struct{}
const plistTemplate = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>%s</string>
<key>ProgramArguments</key>
<array>
<string>%s</string>
<string>-run</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>WorkingDirectory</key>
<string>%s</string>
</dict>
</plist>`
func (s *darwinService) Install(execPath string) error {
installDir := GetInstallDir()
if err := os.MkdirAll(installDir, 0755); err != nil {
return fmt.Errorf("failed to create installation directory: %w", err)
}
// Copy binary to installation directory
installedBinary := filepath.Join(installDir, "bin", filepath.Base(execPath))
if err := os.MkdirAll(filepath.Dir(installedBinary), 0755); err != nil {
return fmt.Errorf("failed to create bin directory: %w", err)
}
if err := copyFile(execPath, installedBinary); err != nil {
return fmt.Errorf("failed to copy binary: %w", err)
}
plistPath := filepath.Join("/Library/LaunchDaemons", ServiceName+".plist")
content := fmt.Sprintf(plistTemplate, ServiceName, installedBinary, installDir)
if err := os.WriteFile(plistPath, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to write plist file: %w", err)
}
if err := exec.Command("launchctl", "load", plistPath).Run(); err != nil {
return fmt.Errorf("failed to load service: %w", err)
}
return nil
}
func (s *darwinService) Uninstall() error {
plistPath := filepath.Join("/Library/LaunchDaemons", ServiceName+".plist")
if err := exec.Command("launchctl", "unload", plistPath).Run(); err != nil {
return fmt.Errorf("failed to unload service: %w", err)
}
if err := os.Remove(plistPath); err != nil {
return fmt.Errorf("failed to remove plist file: %w", err)
}
return nil
}
func (s *darwinService) Status() (bool, error) {
err := exec.Command("launchctl", "list", ServiceName).Run()
return err == nil, nil
}
func (s *darwinService) Start() error {
if err := exec.Command("launchctl", "start", ServiceName).Run(); err != nil {
return fmt.Errorf("failed to start service: %w", err)
}
return nil
}
func (s *darwinService) Stop() error {
if err := exec.Command("launchctl", "stop", ServiceName).Run(); err != nil {
return fmt.Errorf("failed to stop service: %w", err)
}
return nil
}
Linux (linux.go)
On Linux, we use systemd
to manage the service. Define a .service
file and related methods.
File:internal/platform/linux.go
package platform
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
type linuxService struct{}
const systemdServiceTemplate = `[Unit]
Description=%s
[Service]
ExecStart=%s -run
Restart=always
User=root
WorkingDirectory=%s
[Install]
WantedBy=multi-user.target
`
func (s *linuxService) Install(execPath string) error {
installDir := GetInstallDir()
if err := os.MkdirAll(installDir, 0755); err != nil {
return fmt.Errorf("failed to create installation directory: %w", err)
}
installedBinary := filepath.Join(installDir, "bin", filepath.Base(execPath))
if err := os.MkdirAll(filepath.Dir(installedBinary), 0755); err != nil {
return fmt.Errorf("failed to create bin directory: %w", err)
}
if err := copyFile(execPath, installedBinary); err != nil {
return fmt.Errorf("failed to copy binary: %w", err)
}
servicePath := filepath.Join("/etc/systemd/system", ServiceName+".service")
content := fmt.Sprintf(systemdServiceTemplate, ServiceDesc, installedBinary, installDir)
if err := os.WriteFile(servicePath, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to write service file: %w", err)
}
commands := [][]string{
{"systemctl", "daemon-reload"},
{"systemctl", "enable", ServiceName},
{"systemctl", "start", ServiceName},
}
for _, args := range commands {
if err := exec.Command(args[0], args[1:]...).Run(); err != nil {
return fmt.Errorf("failed to execute %s: %w", args[0], err)
}
}
return nil
}
func (s *linuxService) Uninstall() error {
_ = exec.Command("systemctl", "stop", ServiceName).Run()
_ = exec.Command("systemctl", "disable", ServiceName).Run()
servicePath := filepath.Join("/etc/systemd/system", ServiceName+".service")
if err := os.Remove(servicePath); err != nil {
return fmt.Errorf("failed to remove service file: %w", err)
}
return nil
}
func (s *linuxService) Status() (bool, error) {
output, err := exec.Command("systemctl", "is-active", ServiceName).Output()
if err != nil {
return false, nil
}
return string(output) == "active\n", nil
}
func (s *linuxService) Start() error {
if err := exec.Command("systemctl", "start", ServiceName).Run(); err != nil {
return fmt.Errorf("failed to start service: %w", err)
}
return nil
}
func (s *linuxService) Stop() error {
if err := exec.Command("systemctl", "stop", ServiceName).Run(); err != nil {
return fmt.Errorf("failed to stop service: %w", err)
}
return nil
}
Windows (windows.go)
For Windows, use the sc
command to install and uninstall the service.
File:internal/platform/windows.go
package platform
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
type windowsService struct{}
func (s *windowsService) Install(execPath string) error {
installDir := GetInstallDir()
if err := os.MkdirAll(installDir, 0755); err != nil {
return fmt.Errorf("failed to create installation directory: %w", err)
}
installedBinary := filepath.Join(installDir, "bin", filepath.Base(execPath))
if err := os.MkdirAll(filepath.Dir(installedBinary), 0755); err != nil {
return fmt.Errorf("failed to create bin directory: %w", err)
}
if err := copyFile(execPath, installedBinary); err != nil {
return fmt.Errorf("failed to copy binary: %w", err)
}
cmd := exec.Command("sc", "create", ServiceName,
"binPath=", fmt.Sprintf("\"%s\" -run", installedBinary),
"DisplayName=", ServiceDisplay,
"start=", "auto",
"obj=", "LocalSystem")
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to create service: %w", err)
}
descCmd := exec.Command("sc", "description", ServiceName, ServiceDesc)
if err := descCmd.Run(); err != nil {
return fmt.Errorf("failed to set service description: %w", err)
}
if err := exec.Command("sc", "start", ServiceName).Run(); err != nil {
return fmt.Errorf("failed to start service: %w", err)
}
return nil
}
func (s *windowsService) Uninstall() error {
_ = exec.Command("sc", "stop", ServiceName).Run()
if err := exec.Command("sc", "delete", ServiceName).Run(); err != nil {
return fmt.Errorf("failed to delete service: %w", err)
}
// Clean up installation directory
installDir := GetInstallDir()
if err := os.RemoveAll(installDir); err != nil {
return fmt.Errorf("failed to remove installation directory: %w", err)
}
return nil
}
func (s *windowsService) Status() (bool, error) {
output, err := exec.Command("sc", "query", ServiceName).Output()
if err != nil {
return false, nil
}
return strings.Contains(string(output), "RUNNING"), nil
}
func (s *windowsService) Start() error {
if err := exec.Command("sc", "start", ServiceName).Run(); err != nil {
return fmt.Errorf("failed to start service: %w", err)
}
return nil
}
func (s *windowsService) Stop() error {
if err := exec.Command("sc", "stop", ServiceName).Run(); err != nil {
return fmt.Errorf("failed to stop service: %w", err)
}
return nil
}
Step 4: Main File Setup (main.go)
Finally, configure main.go
in cmd/service/main.go
to handle installation, uninstallation, and starting the service.
File:cmd/service/main.go
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"go-service/internal/platform"
"go-service/internal/service"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
install := flag.Bool("install", false, "Install the service")
uninstall := flag.Bool("uninstall", false, "Uninstall the service")
status := flag.Bool("status", false, "Check service status")
start := flag.Bool("start", false, "Start the service")
stop := flag.Bool("stop", false, "Stop the service")
runWorker := flag.Bool("run", false, "Run the service worker")
flag.Parse()
if err := handleCommand(*install, *uninstall, *status, *start, *stop, *runWorker); err != nil {
log.Fatal(err)
}
}
func handleCommand(install, uninstall, status, start, stop, runWorker bool) error {
platformSvc, err := platform.NewService()
if err != nil {
return err
}
execPath, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get executable path: %w", err)
}
switch {
case install:
return platformSvc.Install(execPath)
case uninstall:
return platformSvc.Uninstall()
case status:
running, err := platformSvc.Status()
if err != nil {
return err
}
fmt.Printf("Service is %s\n", map[bool]string{true: "running", false: "stopped"}[running])
return nil
case start:
return platformSvc.Start()
case stop:
return platformSvc.Stop()
case runWorker:
return runService()
default:
return fmt.Errorf("no command specified")
}
}
func runService() error {
svc, err := service.New()
if err != nil {
return fmt.Errorf("failed to create service: %w", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
log.Println("Starting service...")
if err := svc.Start(ctx); err != nil {
return fmt.Errorf("failed to start service: %w", err)
}
log.Println("Service started, waiting for shutdown signal...")
<-sigChan
log.Println("Shutdown signal received, stopping service...")
if err := svc.Stop(); err != nil {
return fmt.Errorf("failed to stop service: %w", err)
}
log.Println("Service stopped successfully")
return nil
}
Building and Managing Your Service
To build your service for different operating systems, use the GOOS
and GOARCH
environment variables. For example, to build for Windows:
GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o go-service.exe ./cmd/service
For Linux:
GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o go-service ./cmd/service
For macOS:
GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w" -o go-service ./cmd/service
Managing Your Service
Once you have built your service for the respective operating system, you can manage it using the following commands.
Note: Ensure to execute the commands with root privileges, as these actions require elevated permissions on all platforms.
- Install the Service: Use the
--install
flag to install the service.
./go-service --install
- Check the Status: To check if the service is running, use:
./go-service --status
- Uninstall the Service: If you need to remove the service, use the
--uninstall
flag:
./go-service --uninstall
Building and Managing Your Service using TaskFile (Optional)
Although you can use Go commands and flags to build and manage the service, I highly recommend using TaskFile. It automates these processes and provides:
Consistent commands across all platforms
Simple YAML-based configuration
Built-in dependency management
Setting Up Task
First, check if Task is installed:
task --version
If not present, install using:
macOS
brew install go-task/tap/go-task
Linux
sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d
Windows
# Using Scoop
scoop install task
# Using Chocolatey
choco install go-task
Task Configuration
Create a Taskfile.yml in your project root:
version: '3'
vars:
BINARY_NAME: go-service
BUILD_DIR: build
tasks:
build:
desc: Build the service binary for current platform
cmds:
- mkdir -p {{.BUILD_DIR}}
- go build -v -ldflags "-s -w" -o {{.BUILD_DIR}}/{{.BINARY_NAME}} ./cmd/service
build:windows:
desc: Build for Windows
cmds:
- mkdir -p {{.BUILD_DIR}}
- GOOS=windows GOARCH=amd64 go build -v -ldflags "-s -w" -o {{.BUILD_DIR}}/{{.BINARY_NAME}}.exe ./cmd/service
build:linux:
desc: Build for Linux
cmds:
- mkdir -p {{.BUILD_DIR}}
- GOOS=linux GOARCH=amd64 go build -v -ldflags "-s -w" -o {{.BUILD_DIR}}/{{.BINARY_NAME}} ./cmd/service
build:darwin-amd64:
desc: Build for macOS (Intel)
cmds:
- mkdir -p {{.BUILD_DIR}}
- GOOS=darwin GOARCH=amd64 go build -v -ldflags "-s -w" -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-amd64 ./cmd/service
build:darwin-arm64:
desc: Build for macOS (Apple Silicon)
cmds:
- mkdir -p {{.BUILD_DIR}}
- GOOS=darwin GOARCH=arm64 go build -v -ldflags "-s -w" -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-arm64 ./cmd/service
build:darwin:
desc: Build for macOS (Universal Binary)
cmds:
- task: build:darwin-amd64
- task: build:darwin-arm64
- mkdir -p {{.BUILD_DIR}}
- lipo -create -output {{.BUILD_DIR}}/{{.BINARY_NAME}} {{.BUILD_DIR}}/{{.BINARY_NAME}}-amd64 {{.BUILD_DIR}}/{{.BINARY_NAME}}-arm64
- rm {{.BUILD_DIR}}/{{.BINARY_NAME}}-amd64 {{.BUILD_DIR}}/{{.BINARY_NAME}}-arm64
build:all:
desc: Build for all platforms
cmds:
- task: build:windows
- task: build:linux
- task: build:darwin
install:
desc: Install the service
deps: [build]
cmds:
- sudo ./{{.BUILD_DIR}}/{{.BINARY_NAME}} -install
uninstall:
desc: Uninstall the service
cmds:
- sudo ./{{.BUILD_DIR}}/{{.BINARY_NAME}} -uninstall
start:
desc: Start the service
cmds:
- sudo ./{{.BUILD_DIR}}/{{.BINARY_NAME}} -start
stop:
desc: Stop the service
cmds:
- sudo ./{{.BUILD_DIR}}/{{.BINARY_NAME}} -stop
status:
desc: Check service status
cmds:
- ./{{.BUILD_DIR}}/{{.BINARY_NAME}} -status
clean:
desc: Clean build artifacts
cmds:
- rm -rf {{.BUILD_DIR}}
default:
desc: Show available tasks
cmds:
- task --list
Using Task Commands
Service management (requires root/administrator privileges):
sudo task install # Install service
task status # Check status
sudo task uninstall # Remove service
Build for your platform:
task build
Cross-platform builds:
task windows # Windows build
task linux # Linux build
task darwin # macOS build
List all available tasks:
task --list
Conclusion
By following this structured approach, you can create a clean and modular service in Go that works seamlessly across multiple platforms. Each platform’s specifics are isolated in their respective files, and the main.go
file remains straightforward and easy to maintain.
For the complete code, please refer to my Go service repository on GitHub.
Subscribe to my newsletter
Read articles from Anshuman directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Anshuman
Anshuman
I love to explore new tech and build cool stuff with it.I love reading & writing tech blogs whenever I find a not so common solution to a problem that I personally faced.