How to Deploy a Go App to a VPS with systemd

Deploy a Go app to a VPS by building a binary, transferring it, and configuring a systemd service unit for automatic management.

The midnight deployment

You finish writing a Go web server at 2 AM. The code runs perfectly on your laptop. You push it to a cheap VPS, run ./server, and it serves requests. Then you close your terminal. The process dies. You SSH back in, run it again, and it works until you accidentally hit Ctrl+C. Running a background process with & or nohup feels like duct tape. You need something that survives reboots, restarts on crashes, and captures logs without you babysitting it. That is exactly what systemd exists for.

Why systemd instead of a shell script

Think of systemd as a professional facility manager for your server. Instead of manually starting every light, HVAC unit, and security camera when the building opens, you hand the manager a blueprint. The blueprint says which systems depend on others, what user account should run them, how to restart them if they trip a breaker, and where to write the maintenance log. When the server boots, systemd reads those blueprints and handles the heavy lifting. You just define the rules once.

Shell scripts can launch processes, but they lack state tracking. They do not know if a process crashed three minutes ago. They do not automatically restart it. They do not integrate with the system journal for centralized logging. systemd fills those gaps by treating every long-running program as a managed service. You declare the desired state. The init system enforces it.

Cross-compilation removes the dependency on a local Linux environment. Build locally, ship the artifact.

Cross-compiling the binary

The first step is building a binary that matches your VPS environment. Go ships with a cross-compiler built in. You set two environment variables before running the build command. GOOS tells the compiler which operating system to target. GOARCH specifies the CPU architecture. The compiler generates machine code for that exact combination without needing the target OS installed locally.

Here is the build command for a standard 64-bit Linux server:

# Target the Linux kernel and x86-64 processors
# Output the compiled binary as myapp in the current directory
GOOS=linux GOARCH=amd64 go build -o myapp main.go

If your VPS uses an ARM processor, like a Raspberry Pi or a modern cloud instance, swap amd64 for arm64. The compiler will adjust the instruction set accordingly. Run go tool dist list to see every supported combination. The resulting file is a standalone executable. It contains the Go runtime, your code, and all compiled dependencies. You do not need to install Go on the server.

You can also strip debug information and embed version strings to keep the binary lean and traceable. The -ldflags flag passes arguments directly to the linker.

Here is a production-ready build command:

# Strip debug symbols to reduce binary size
# Embed a version string into the binary for runtime identification
GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.Version=1.0.0" -o myapp main.go

The -s flag removes the symbol table. The -w flag removes DWARF debugging information. The -X flag replaces a string variable at link time. Your Go code can read main.Version at runtime to report its build version in HTTP headers or logs. The compiler will reject the program with undefined: main.Version if the variable does not exist in your source code. Declare it as var Version = "dev" before building.

Cross-compilation removes the dependency on a local Linux environment. Build locally, ship the artifact.

The service unit file

systemd reads configuration files called unit files. They use a simple INI-style format with sections marked by brackets. The [Unit] section defines metadata and dependencies. The [Service] section controls execution. The [Install] section tells systemd where to place the service in the boot sequence.

Here is a complete unit file for a Go web server:

# Describe what this service does for humans and logs
[Unit]
Description=My Go Web Application
# Wait until the network stack is fully initialized before starting
After=network.target

# Define how the process runs and recovers
[Service]
# Run as a non-root user to limit damage from exploits
User=ubuntu
# Set the working directory so relative paths resolve correctly
WorkingDirectory=/opt/myapp
# The absolute path to the compiled binary
ExecStart=/opt/myapp/myapp
# Restart the process if it exits with a non-zero status
Restart=on-failure
# Pass environment variables directly to the process
Environment=PORT=8080
# Capture stdout and stderr into the journal
StandardOutput=journal
StandardError=journal

# Define when this service should be activated
[Install]
# Start this service when the system reaches a multi-user state
WantedBy=multi-user.target

The User directive is critical for security. Running a network-facing application as root gives it unrestricted access to the entire filesystem. If an attacker finds a vulnerability in your code, they inherit root privileges. Create a dedicated service account or use your existing non-root user. The Restart=on-failure directive tells systemd to automatically relaunch the binary if it crashes or returns an error code. The StandardOutput=journal lines route your fmt.Println and log.Printf calls into the systemd journal instead of dumping them to a terminal that might disconnect.

Save this file as /etc/systemd/system/myapp.service. The filename must match the service name you will reference in commands. The Go community convention for error handling applies here too: if err != nil { return err } is verbose by design. systemd configuration is similarly explicit. Every directive must be spelled out. There are no implicit defaults for security-sensitive fields.

systemd unit files are configuration, not code. Keep them declarative and let the init system handle the state machine.

Managing the lifecycle and logs

After placing the unit file, systemd does not know it exists yet. You must reload the daemon configuration. Then you enable the service for automatic startup and launch it immediately.

Here are the lifecycle commands:

# Tell systemd to scan /etc/systemd/system for new or changed files
sudo systemctl daemon-reload
# Create symlinks so the service starts automatically on boot
sudo systemctl enable myapp
# Launch the process immediately without waiting for a reboot
sudo systemctl start myapp

The daemon-reload step is mandatory after every edit to a unit file. Skipping it means systemd continues running the old configuration in memory. The enable command creates symbolic links in the appropriate target directories. It does not start the process. The start command launches it now. You can combine them with && to run them sequentially, but keeping them separate makes debugging easier if one fails.

Check the current state with sudo systemctl status myapp. The output shows whether the process is active, how long it has been running, and the most recent log lines. If the service fails to start, the status command displays the exit code and a snippet of the journal log.

You can stream the full log output using journalctl. The -u flag filters by unit name. The -f flag follows new entries in real time.

Here is the log inspection command:

# Filter journal entries for the myapp service
# Follow new log lines as they are written
sudo journalctl -u myapp -f

The journal stores logs in a binary format that supports structured querying. You can filter by priority, time range, or field value. This replaces the old practice of redirecting output to rotating text files. The system handles log rotation and compression automatically.

systemctl commands are state transitions. Enable schedules it. Start runs it. Reload updates the blueprint.

Pitfalls and runtime surprises

Deploying Go applications to systemd introduces a few common failure modes. The first is permission mismatches. If your binary tries to bind to port 80 or 443, the kernel blocks it unless the process runs as root or has the CAP_NET_BIND_SERVICE capability. The service will crash immediately, and systemd will log permission denied in the journal. Run your app on a high port like 8080 and reverse proxy with Nginx or Caddy instead.

The second pitfall is working directory confusion. Go programs that read configuration files or static assets using relative paths will fail if WorkingDirectory is missing or incorrect. The compiler will not catch this. The runtime will panic with open config.yaml: no such file or directory. Always set WorkingDirectory in the unit file to match where your assets live.

The third issue is goroutine leaks during shutdown. When you run sudo systemctl stop myapp, systemd sends a SIGTERM signal. If your Go program does not handle the signal gracefully, the kernel kills the process after a timeout. Any in-flight HTTP requests drop, and database transactions roll back. Add a signal handler that waits for active requests to finish before calling os.Exit(0).

Here is a minimal graceful shutdown pattern:

// GracefulShutdown handles OS signals and stops the server cleanly
func GracefulShutdown(server *http.Server) {
    // Create a channel to receive OS signals
    quit := make(chan os.Signal, 1)
    // Listen for interrupt and termination signals
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    // Block until a signal arrives
    <-quit
    // Create a context with a five-second timeout for cleanup
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    // Stop accepting new connections and wait for active ones
    server.Shutdown(ctx)
}

The context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. The server.Shutdown method stops the listener, closes idle connections, and waits for active requests to complete. If the timeout expires, it returns an error and the program exits. The worst goroutine bug is the one that never logs.

Environment variables also behave differently in systemd. The Environment directive sets variables for the process only. It does not modify the system-wide environment. If your code calls os.Getenv("DATABASE_URL"), it will find the value. If you forget to declare it in the unit file, the runtime returns an empty string. The compiler will not warn you about missing environment variables. They are runtime configuration, not compile-time constants.

systemd expects deterministic behavior. Handle signals, set working directories, and declare dependencies explicitly.

When to use systemd versus other options

Use systemd when you need a long-running process that survives reboots and restarts automatically on crashes. Use a Docker container with a restart policy when you want isolated dependencies and consistent environments across development and production. Use a process manager like supervisord when you are on a legacy system that predates systemd. Use a cloud managed service like AWS ECS or Google Cloud Run when you want to abstract away server maintenance entirely. Use a simple shell script with nohup when you are debugging a one-off task and do not need persistence.

Where to go next