Project Deployment and Optimization
Lesson 28: Project Deployment and Optimization
Real-World Analogy
Imagine you've opened a restaurant. The recipes are written (code is done), but to actually start business, you need:
- Location and renovation — Cross-compilation lets the same recipe work in different cities (running on different platforms)
- Kitchen design — Docker multi-stage builds are like only bringing cooking tools into the kitchen, not extra furniture
- Employee manual — Environment variable configuration is like different branches using different business hours, but the manual format is the same
- Security cameras — Log management records every dish's preparation process, traceable when issues arise
- Health check report — Performance profiling is like a regular checkup, finding which part is slow
- Closing procedure — Graceful shutdown is like not kicking out customers who are still eating at closing time, waiting for them to finish before closing
Core Concepts
| Concept | Description |
|---|---|
| Cross-compilation | Compiling executables for one platform on another platform |
| Multi-stage build | Docker build separating compilation and runtime environments, reducing image size |
| Environment variables | Configuring program behavior without modifying code |
| Structured logging | Using log/slog to output machine-parseable log formats |
| Performance profiling | Using pprof to identify CPU and memory bottlenecks |
| Graceful shutdown | After receiving a termination signal, waiting for in-progress requests to complete before exiting |
Basic Syntax and Usage
1. Cross-Compilation
Go natively supports cross-compilation, just set GOOS and GOARCH environment variables:
# Compile Linux amd64 version (run on Windows/Mac)
GOOS=linux GOARCH=amd64 go build -o myapp-linux .
# Compile Windows version (run on Linux/Mac)
GOOS=windows GOARCH=amd64 go build -o myapp.exe .
# Compile macOS ARM (M1/M2) version
GOOS=darwin GOARCH=arm64 go build -o myapp-darwin .
CGO is disabled by default. If you depend on C libraries, you need to install cross-compilation toolchains.
2. Docker Multi-Stage Build
# ---- Build Stage ----
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server .
# ---- Runtime Stage ----
FROM alpine:3.19
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
CMD ["./server"]
3. Environment Variable Configuration
package main
import (
"fmt"
"os"
"strconv"
)
func main() {
// Read environment variables with default values
port := getEnv("APP_PORT", "8080")
dbHost := getEnv("DB_HOST", "localhost")
debug := getEnv("DEBUG", "false")
fmt.Printf("Startup config: port=%s, db=%s, debug=%s\n", port, dbHost, debug)
}
// getEnv gets an environment variable, returns default value if not set
func getEnv(key, defaultVal string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultVal
}
.env files with the godotenv library, but don't commit .env files to version control.
4. Structured Logging (log/slog)
Go 1.21 introduced the standard library log/slog, supporting JSON format output:
package main
import (
"log/slog"
"os"
)
func main() {
// Create JSON format handler
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
// Use structured logging
logger.Info("Service started",
"port", 8080,
"env", "production",
)
logger.Error("Database connection failed",
"host", "db.example.com",
"error", "connection refused",
)
}
slog supports setting a global default logger via slog.SetDefault(), which can be used uniformly across the entire project.
5. Performance Profiling (pprof)
package main
import (
"fmt"
"net/http"
_ "net/http/pprof" // Import registers pprof routes
"time"
)
func main() {
// Start pprof service on a separate port
go func() {
fmt.Println("pprof listening on :6060")
http.ListenAndServe(":6060", nil)
}()
// Your main business logic
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(10 * time.Millisecond)
fmt.Fprintln(w, "Hello, World!")
})
http.ListenAndServe(":8080", nil)
}
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30 can collect 30 seconds of CPU data.
6. Graceful Shutdown
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second) // Simulate long-running request
fmt.Fprintln(w, "Processing complete")
})
server := &http.Server{
Addr: ":8080",
Handler: mux,
}
// Start server in a goroutine
go func() {
fmt.Println("Server listening on :8080")
if err := server.ListenAndServe(); err != http.ErrServerClosed {
fmt.Printf("Server exited abnormally: %v\n", err)
}
}()
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
fmt.Println("\nReceived shutdown signal, gracefully exiting...")
// Give in-progress requests up to 10 seconds to complete
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
fmt.Printf("Forced shutdown: %v\n", err)
} else {
fmt.Println("Server shut down gracefully")
}
}
server.Shutdown() stops accepting new connections and waits for existing connections to finish processing before returning.
Example: Basic Cross-Compilation Script (Difficulty ⭐)
Create a script that compiles binaries for multiple platforms in one go:
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
// Target platform list
type Target struct {
OS string
Arch string
}
func main() {
targets := []Target{
{"linux", "amd64"},
{"linux", "arm64"},
{"darwin", "amd64"},
{"darwin", "arm64"},
{"windows", "amd64"},
}
outputDir := "dist"
os.MkdirAll(outputDir, 0755)
for _, t := range targets {
outputName := fmt.Sprintf("myapp-%s-%s", t.OS, t.Arch)
if t.OS == "windows" {
outputName += ".exe"
}
outputPath := filepath.Join(outputDir, outputName)
fmt.Printf("Compiling: %s/%s -> %s\n", t.OS, t.Arch, outputPath)
cmd := exec.Command("go", "build", "-o", outputPath, ".")
cmd.Env = append(os.Environ(),
"GOOS="+t.OS,
"GOARCH="+t.Arch,
"CGO_ENABLED=0",
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Printf("Compilation failed %s/%s: %v\n", t.OS, t.Arch, err)
}
}
fmt.Println("All compilations complete!")
}
Run:
go run build.go
Output directory structure:
dist/
├── myapp-darwin-amd64
├── myapp-darwin-arm64
├── myapp-linux-amd64
├── myapp-linux-arm64
└── myapp-windows-amd64.exe
Example: HTTP Service with Environment Variable Configuration (Difficulty ⭐⭐)
Demonstrates a complete environment variable configuration scheme:
package main
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
)
// Config application configuration struct
type Config struct {
Port int `json:"port"`
Env string `json:"env"`
LogLevel string `json:"log_level"`
DBHost string `json:"db_host"`
DBPort int `json:"db_port"`
IdleTimeout int `json:"idle_timeout"` // seconds
}
// LoadConfig loads configuration from environment variables
func LoadConfig() Config {
return Config{
Port: getEnvInt("APP_PORT", 8080),
Env: getEnvStr("APP_ENV", "development"),
LogLevel: getEnvStr("LOG_LEVEL", "info"),
DBHost: getEnvStr("DB_HOST", "localhost"),
DBPort: getEnvInt("DB_PORT", 5432),
IdleTimeout: getEnvInt("IDLE_TIMEOUT", 30),
}
}
func getEnvStr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func getEnvInt(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return fallback
}
func (c Config) String() string {
data, _ := json.MarshalIndent(c, "", " ")
return string(data)
}
func main() {
cfg := LoadConfig()
// Configure log level based on environment
var level slog.Level
switch cfg.LogLevel {
case "debug":
level = slog.LevelDebug
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
default:
level = slog.LevelInfo
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
}))
slog.SetDefault(logger)
logger.Info("Application config loaded", "config", cfg.String())
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"env": cfg.Env,
})
})
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
logger.Info("Request received", "method", r.Method, "path", r.URL.Path)
fmt.Fprintf(w, "Hello from %s environment!", cfg.Env)
})
server := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: mux,
IdleTimeout: time.Duration(cfg.IdleTimeout) * time.Second,
}
go func() {
logger.Info("Service started", "port", cfg.Port, "env", cfg.Env)
if err := server.ListenAndServe(); err != http.ErrServerClosed {
logger.Error("Service error", "error", err)
os.Exit(1)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
logger.Info("Shutting down service...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
server.Shutdown(ctx)
logger.Info("Service shut down")
}
Start:
# Use default configuration
go run main.go
# Custom configuration
APP_PORT=3000 APP_ENV=production LOG_LEVEL=debug go run main.go
Test requests:
# Health check
curl http://localhost:8080/health
# Homepage
curl http://localhost:8080/
Example: Complete Docker Deployment Solution (Difficulty ⭐⭐⭐)
A complete deployment solution including Dockerfile, docker-compose, and Makefile:
Project structure:
myproject/
├── main.go
├── go.mod
├── go.sum
├── Dockerfile
├── docker-compose.yml
├── Makefile
└── .env.example
main.go:
package main
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
_ "net/http/pprof"
"os"
"os/signal"
"runtime"
"syscall"
"time"
)
type AppConfig struct {
Port string
Env string
LogLevel string
PprofPort string
}
func loadConfig() AppConfig {
return AppConfig{
Port: getEnv("APP_PORT", "8080"),
Env: getEnv("APP_ENV", "production"),
LogLevel: getEnv("LOG_LEVEL", "info"),
PprofPort: getEnv("PPROF_PORT", "6060"),
}
}
func getEnv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func setupLogger(level string) *slog.Logger {
var lvl slog.Level
switch level {
case "debug":
lvl = slog.LevelDebug
case "warn":
lvl = slog.LevelWarn
case "error":
lvl = slog.LevelError
default:
lvl = slog.LevelInfo
}
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}))
}
func main() {
cfg := loadConfig()
logger := setupLogger(cfg.LogLevel)
slog.SetDefault(logger)
// Start pprof (only in non-production or when explicitly enabled)
if cfg.Env != "production" || os.Getenv("ENABLE_PPROF") == "true" {
go func() {
logger.Info("pprof service started", "port", cfg.PprofPort)
http.ListenAndServe(":"+cfg.PprofPort, nil)
}()
}
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "ok",
"env": cfg.Env,
"goroutine": runtime.NumGoroutine(),
"uptime": time.Since(startTime).String(),
})
})
mux.HandleFunc("GET /api/info", func(w http.ResponseWriter, r *http.Request) {
logger.Info("info request", "remote", r.RemoteAddr)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"app": "myproject",
"version": version,
"go": runtime.Version(),
"env": cfg.Env,
})
})
mux.HandleFunc("POST /api/data", func(w http.ResponseWriter, r *http.Request) {
// Simulate data processing
time.Sleep(100 * time.Millisecond)
logger.Info("Data processing complete", "content_length", r.ContentLength)
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"result": "created"})
})
server := &http.Server{
Addr: ":" + cfg.Port,
Handler: mux,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Graceful shutdown logic
done := make(chan struct{})
go func() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
sig := <-quit
logger.Info("Received shutdown signal", "signal", sig)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Stop pprof
// Note: standard library pprof has no Shutdown, relies on process exit
if err := server.Shutdown(ctx); err != nil {
logger.Error("Server shutdown failed", "error", err)
}
close(done)
}()
logger.Info("Service started",
"port", cfg.Port,
"env", cfg.Env,
"version", version,
)
if err := server.ListenAndServe(); err != http.ErrServerClosed {
logger.Error("Server exited abnormally", "error", err)
os.Exit(1)
}
<-done
logger.Info("Service fully shut down")
}
var (
startTime = time.Now()
version = "1.0.0"
)
Dockerfile:
# ========== Build Stage ==========
FROM golang:1.22-alpine AS builder
# Install git (go mod may need it)
RUN apk add --no-cache git
WORKDIR /build
# Copy dependency files first, leveraging Docker cache layers
COPY go.mod go.sum ./
RUN go mod download
# Copy source and compile
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o /app/server .
# ========== Runtime Stage ==========
FROM alpine:3.19
# Install CA certificates (for HTTPS requests) and timezone data
RUN apk --no-cache add ca-certificates tzdata
# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
# Copy binary from build stage
COPY --from=builder /app/server .
COPY --from=builder /build/.env.example .env.example
# Run as non-root user
USER appuser
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:8080/health || exit 1
CMD ["./server"]
docker-compose.yml:
version: '3.8'
services:
app:
build: .
ports:
- "8080:8080"
environment:
- APP_ENV=production
- LOG_LEVEL=info
- APP_PORT=8080
- ENABLE_PPROF=false
restart: unless-stopped
deploy:
resources:
limits:
memory: 256M
cpus: '0.5'
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
Makefile:
APP_NAME := myproject
VERSION := 1.0.0
.PHONY: build run clean docker docker-run docker-stop
# Local build
build:
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/$(APP_NAME) .
# Local run
run: build
./bin/$(APP_NAME)
# Clean
clean:
rm -rf bin/
# Build Docker image
docker:
docker build -t $(APP_NAME):$(VERSION) .
# Start Docker container
docker-run:
docker-compose up -d
# Stop Docker container
docker-stop:
docker-compose down
# Cross-compile all platforms
cross-build:
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/$(APP_NAME)-linux-amd64 .
GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/$(APP_NAME)-darwin-amd64 .
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/$(APP_NAME)-windows-amd64.exe .
.env.example:
APP_ENV=production
APP_PORT=8080
LOG_LEVEL=info
ENABLE_PPROF=false
PPROF_PORT=6060
Build and deploy workflow:
# 1. Local test
make run
# 2. Build Docker image
make docker
# 3. Start container
make docker-run
# 4. Test service
curl http://localhost:8080/health
curl http://localhost:8080/api/info
# 5. View logs
docker-compose logs -f
# 6. Stop service
make docker-stop
Scenario 1: Production Logging and Monitoring
In a real web service, logs need to be collected into a centralized logging system:
package main
import (
"context"
"log/slog"
"net/http"
"os"
"time"
)
// RequestLogger middleware: records detailed information for each request
func RequestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Wrap ResponseWriter to capture status code
rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(rw, r)
slog.Info("HTTP request",
"method", r.Method,
"path", r.URL.Path,
"status", rw.statusCode,
"duration_ms", time.Since(start).Milliseconds(),
"remote_addr", r.RemoteAddr,
"user_agent", r.UserAgent(),
)
})
}
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
// RecoveryMiddleware catches panics and logs them
func RecoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
slog.Error("Request processing panic",
"error", err,
"path", r.URL.Path,
"method", r.Method,
)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
func main() {
// Production uses JSON format for easy log collection system parsing
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
mux := http.NewServeMux()
mux.HandleFunc("GET /api/users", func(w http.ResponseWriter, r *http.Request) {
// Simulate business logic
ctx := r.Context()
slog.DebugContext(ctx, "Querying user list", "page", 1)
// ...
})
// Middleware chain: Recovery -> Logger -> Router
handler := RecoveryMiddleware(RequestLogger(mux))
server := &http.Server{Addr: ":8080", Handler: handler}
slog.Info("Service started", "addr", server.Addr)
server.ListenAndServe()
}
After running, each request outputs structured logs:
{"time":"2024-01-15T10:30:00Z","level":"INFO","msg":"HTTP request","method":"GET","path":"/api/users","status":200,"duration_ms":5,"remote_addr":"127.0.0.1","user_agent":"curl/7.68.0"}
Scenario 2: Using pprof to Locate Performance Bottlenecks
When service response slows down, use pprof for diagnosis:
package main
import (
"fmt"
"net/http"
_ "net/http/pprof"
"runtime"
"strings"
"time"
)
func main() {
// Main service
mux := http.NewServeMux()
// Simulate an endpoint with performance issues
mux.HandleFunc("GET /api/search", func(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
result := slowSearch(query) // Intentionally slow function
fmt.Fprint(w, result)
})
// pprof registers on default http.DefaultServeMux, needs separate port
go func() {
fmt.Println("pprof: http://localhost:6060/debug/pprof/")
http.ListenAndServe(":6060", nil)
}()
fmt.Println("Main service: http://localhost:8080")
http.ListenAndServe(":8080", mux)
}
// slowSearch simulates a CPU-intensive function with memory allocation issues
func slowSearch(query string) string {
var results []string
// Intentionally allocate memory repeatedly in a loop
for i := 0; i < 100000; i++ {
data := fmt.Sprintf("item_%d_%s", i, query) // Allocate new string each iteration
if strings.Contains(data, query) {
results = append(results, data)
}
}
// Simulate additional delay
time.Sleep(50 * time.Millisecond)
return fmt.Sprintf("Found %d results", len(results))
}
Using pprof for analysis:
# 1. Install pprof tool (if not already installed)
go install github.com/google/pprof@latest
# 2. Collect 30 seconds of CPU profile
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
# 3. View in pprof interactive interface
(pprof) top # View most CPU-intensive functions
(pprof) list slowSearch # View specific code line costs
(pprof) web # Generate visualization (requires graphviz)
# 4. View memory allocation
go tool pprof http://localhost:6060/debug/pprof/heap
# 5. View goroutine count (detect leaks)
go tool pprof http://localhost:6060/debug/pprof/goroutine
# 6. Use browser to view all metrics
# Open http://localhost:6060/debug/pprof/
❓ FAQ
Q1: What to do when encountering cgo: exec gcc: exec: "gcc": not found during cross-compilation?
This is because the code uses CGO. If you don't need C library dependencies, set CGO_ENABLED=0:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o myapp .
If you do need CGO, you need to install cross-compilation toolchains for the target platform (like gcc-aarch64-linux-gnu).
Q2: Docker image is too large, how to further reduce size?
Use scratch as the base image (no system tools at all):
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /server
CMD ["/server"]
Combined with removing debug info during compilation: go build -ldflags="-s -w". -s removes the symbol table, -w removes DWARF debug info, typically reducing size by 20-30%.
Q3: How does log/slog compare to third-party logging libraries (like zap, zerolog)?
log/slog's advantage is being a standard library with no additional dependencies, and its performance is sufficient for most scenarios. If your application has extremely high log volume (tens of thousands per second), zap's zero-allocation design will be faster. For new projects, start with slog; consider switching only if you encounter performance bottlenecks.
Q4: During graceful shutdown, how to ensure WebSocket connections are handled correctly?
http.Server.Shutdown() only handles HTTP requests. For long-lived connections (WebSocket), you need to manage the connection pool yourself:
var connections sync.Map // Store active connections
// Register when new connection arrives
connections.Store(conn, true)
// On shutdown, iterate all connections and send close frames
connections.Range(func(key, value any) bool {
wsConn := key.(*websocket.Conn)
wsConn.WriteMessage(websocket.CloseMessage, closeMsg)
wsConn.Close()
return true
})
📖 Summary
In this lesson we learned the complete workflow from development to deployment for Go projects:
- Cross-compilation lets you compile target platform binaries on any platform. Go natively supports this — just set
GOOSandGOARCH - Docker multi-stage builds separate the compilation environment from the runtime environment, significantly reducing image size while improving security
- Environment variable configuration is a 12-Factor App best practice, letting the same code adapt to different environments
log/slogprovides standard library-level structured logging, with JSON format for easy log collection system parsingpprofis Go's built-in performance analysis tool, capable of locating CPU, memory, goroutine, and other issues- Graceful shutdown ensures that after receiving a termination signal, the service waits for in-progress requests to complete before exiting, avoiding data loss
After mastering these skills, you can deploy Go projects to production environments safely and efficiently.
📝 Exercises
Exercise 1: Write a Cross-Compilation Script
Write a Go program that automatically compiles the current project for linux/amd64, darwin/arm64, windows/amd64 platforms, with output filenames containing version and platform information.
Exercise 2: Add Request Logging Middleware
Add logging middleware to an HTTP service that records for each request: method, path, status code, response time, client IP. Use log/slog for JSON format output.
Exercise 3: Docker Deploy Your Project
Write a complete Dockerfile and docker-compose.yml for any project you've written in previous lessons (e.g., Todo API), with the following requirements:
- Use multi-stage builds
- Final image based on
alpineorscratch - Include health checks
- Run as non-root user
Next Lesson
After completing this lesson, you've mastered the core skills for Go project deployment. Next we'll enter the practical project phase: Lesson 29: Practical Project (Part 1)



