How to Implement a Daemon Process in Go

Go lacks native daemon support, so you must detach the process using shell commands like nohup or configure it as a systemd service.

The terminal closes, the process dies

You write a Go program that monitors a directory or serves an API. You run it in the terminal, and it works. You close the terminal, and the process dies. Or you try to run it in the background with &, and the output floods your shell. You need the program to run independently of the terminal, surviving reboots, logging properly, and restarting if it crashes. That's a daemon.

Go does not have a magic daemon() function. The language treats all processes equally. A daemon is just a process that has severed ties with the user's shell. It doesn't read from standard input. It doesn't write to standard output. It logs to files or the system journal. It often changes its working directory so it doesn't lock a mount point.

The old way to build a daemon in Go involves self-spawning, redirecting file descriptors, and calling setsid. That approach works, but it's fragile. It makes debugging harder, it breaks on Windows, and it reinvents functionality that the operating system already provides. The modern approach is simpler. Write a foreground process that handles signals and logs correctly. Hand the process to systemd or your container runtime. The OS handles the daemonization. Go handles the logic.

Daemons are just processes that forgot their terminal. Let the OS manage the detachment.

What a daemon actually is

A daemon is a background process without a controlling terminal. In Unix history, the term comes from Maxwell's Demon, a thought experiment about a tiny creature managing energy. In systems programming, a daemon is a process that runs independently of the user session.

When you run a command in the shell, the process inherits the shell's standard input, output, and error streams. It belongs to the shell's session and process group. If the shell exits, the OS sends a SIGHUP signal to the process group. The process dies.

A daemon breaks this chain. It creates a new session. It closes the inherited streams. It redirects output to files. It ignores SIGHUP. The result is a process that survives the user logging out. It runs until it finishes, crashes, or receives a termination signal.

Go does not export a daemon package. You can find third-party libraries that wrap the Unix daemonization steps, but they often hide complexity that causes subtle bugs. The community convention is to avoid daemonizing inside the application code. Instead, write a well-behaved foreground service. Use an init system to run it in the background. This keeps the code portable and testable. It also lets the OS handle restarts, logging, and resource limits.

Goroutines are cheap. Daemons are not magic.

The self-spawn pattern

Sometimes you need a quick background process for a CLI tool. You don't have access to systemd. You just want the command to run and return the prompt. The self-spawn pattern handles this. The program detects a flag, spawns a copy of itself, and exits. The copy continues running without the terminal.

Here's the simplest self-spawn implementation. The program checks for a --daemon flag. If the flag is missing, it spawns a child process with the flag and exits. The child runs the worker logic.

package main

import (
	"fmt"
	"os"
	"os/exec"
)

// main handles the self-spawn logic for background execution.
func main() {
	// Detect if this instance is the detached child.
	if len(os.Args) > 1 && os.Args[1] == "--daemon" {
		runWorker()
		return
	}

	// Spawn a new process with the daemon flag.
	cmd := exec.Command(os.Args[0], "--daemon")
	// Close standard streams so the child doesn't inherit the terminal.
	cmd.Stdout = nil
	cmd.Stderr = nil
	cmd.Stdin = nil
	// Start the child process without waiting for it to finish.
	if err := cmd.Start(); err != nil {
		fmt.Fprintf(os.Stderr, "spawn failed: %v\n", err)
		os.Exit(1)
	}

	// Parent exits, leaving the child running independently.
	os.Exit(0)
}

// runWorker executes the long-running task.
func runWorker() {
	// Block forever to keep the process alive.
	select {}
}

The code sets cmd.Stdout = nil. This tells the OS to close the standard output file descriptor in the child. The child won't write to the terminal. cmd.Start() forks a new process. The parent calls os.Exit(0). The shell sees the parent exit and returns the prompt. The child is now running. It has no controlling terminal. It runs runWorker. If you close the shell, the child survives.

This pattern works on Linux and macOS. Windows handles background processes differently. The exec.Command approach works, but Windows doesn't have the same concept of a controlling terminal. You might need cmd.SysProcAttr with CreationFlags to hide the console window.

Self-spawning works, but it's a hack. It leaves no PID file. It provides no restart capability. It makes logging difficult. Use it for simple CLI tools, not for production services.

Walkthrough: fork and exit

When you run ./app, the main function checks arguments. No --daemon flag. It creates a command pointing to itself. It sets cmd.Stdout = nil. This closes the file descriptor in the child. cmd.Start() invokes the execve system call. The OS creates a new process. The parent continues. It calls os.Exit(0). The parent process terminates. The child process runs main again. This time, os.Args contains --daemon. The child skips the spawn logic and calls runWorker.

The child process inherits the parent's environment and file descriptors, except for the ones explicitly closed. By setting cmd.Stdout = nil, you ensure the child doesn't write to the terminal. If you omit this, the child inherits the terminal. Closing the terminal sends SIGHUP to the child. The child dies.

The self-spawn pattern is a form of double-forking. The parent exits, which detaches the child from the shell's process group. The child becomes an orphan. The init process adopts it. The child runs independently.

If you forget to close the streams, the compiler won't complain. The runtime will behave unexpectedly. The child might block on I/O. It might crash with a broken pipe error. Always close the streams in a daemon.

Trust gofmt. Argue logic, not formatting.

The modern approach: foreground service

Production systems use init systems. systemd on Linux. launchd on macOS. Service managers on Windows. These tools handle daemonization. They start the process. They redirect output. They restart on failure. They manage dependencies. Your Go program should run in the foreground. It should log to standard output. It should handle signals for graceful shutdown.

Here's a realistic foreground service. It logs to a file. It handles SIGINT and SIGTERM. It uses context for cancellation. This is the pattern you use with systemd. The service file runs the binary. The binary runs until cancelled.

package main

import (
	"context"
	"log"
	"os"
	"os/signal"
	"syscall"
)

// main sets up logging and signal handling for a production daemon.
func main() {
	// Open a log file for persistent output.
	f, err := os.OpenFile("daemon.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
	if err != nil {
		log.Fatalf("cannot open log: %v", err)
	}
	// Redirect log output to the file.
	log.SetOutput(f)
	defer f.Close()

	// Create a context that cancels on interrupt signals.
	ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer cancel()

	log.Println("daemon starting")
	// Run the worker with the cancellation context.
	runWorker(ctx)
	log.Println("daemon stopped")
}

// runWorker performs the background work until context cancellation.
func runWorker(ctx context.Context) {
	// Wait for the context to be cancelled.
	<-ctx.Done()
}

The code opens a log file. It redirects the log package to the file. This ensures output persists even if the init system doesn't capture it. It creates a context using signal.NotifyContext. This context cancels when the process receives SIGINT or SIGTERM. The worker waits on the context. When the signal arrives, the context cancels. The worker stops. The program exits cleanly.

This pattern is portable. It works with systemd, Docker, Kubernetes, and manual execution. It handles signals correctly. It logs output. It's easy to debug. Run the binary in the terminal. Press Ctrl+C. The program stops gracefully.

Context is plumbing. Run it through every long-lived call site.

Pitfalls and runtime traps

Daemon code introduces subtle bugs. Goroutine leaks are the most common. If you spawn goroutines in a daemon, they must stop when the context cancels. Otherwise, the process hangs. The main function returns, but the goroutines keep running. The process never exits. The init system sees a timeout and kills the process.

Always pass the context to goroutines. Check ctx.Done() in loops. Use select to wait for cancellation. If a goroutine blocks on I/O, use a cancellable operation. http.NewRequestWithContext cancels the request. os/exec commands can be killed.

File descriptors are another trap. Inherited file descriptors can cause issues. If the parent process opens a file and spawns a child, the child inherits the file descriptor. If the parent closes the file, the child still has the descriptor open. This can lock files. It can prevent log rotation. Close unused file descriptors in the child. Or better, let the init system handle the process lifecycle.

Working directory matters. If the daemon changes to a directory that gets unmounted, it can lock the mount. Daemons often change to the root directory. os.Chdir("/") ensures the process doesn't hold a reference to a mount point.

Windows behavior differs. os.StartProcess and exec.Command work, but console handling is different. Windows processes have a console window. Hiding the window requires SysProcAttr. The self-spawn pattern might leave a console window open. Use cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: syscall.CREATE_NO_WINDOW} on Windows.

If you forget to import syscall, the compiler rejects the program with undefined: syscall. If you use context.Background() but don't pass it to your worker, you lose the cancellation path. The compiler won't catch that. The runtime will hang.

The worst goroutine bug is the one that never logs.

Decision: background execution strategies

Choose the right tool for the job. Don't daemonize in code unless you have to.

Use the self-spawn pattern when you need a quick background process for a CLI tool and don't have access to an init system.

Use systemd or a service manager when you run on Linux and need automatic restarts, logging, and dependency management.

Use a wrapper script with nohup or screen when you are on a remote server without root access and need a temporary background task.

Use os/exec with SysProcAttr when you need to hide the console window on Windows.

Use plain foreground execution when you are developing locally; debugging a daemon is harder than debugging a foreground process.

Let the OS manage the process. You manage the logic.

Where to go next