Dockerfile, compose.yml и Makefile для Go-сайта

GoEchotemplDockerCaddy

Как устроена сборка и деплой этого сайта: многоступенчатый Dockerfile собирает бинарник на ~15MB, compose поднимает его вместе с Caddy, а Makefile прячет все команды за короткими алиасами.

Dockerfile: три стадии

FROM ghcr.io/a-h/templ:latest AS templ
COPY --chown=65532:65532 . /app
WORKDIR /app
RUN ["templ", "generate"]

FROM golang:1.26-alpine AS builder
WORKDIR /app
RUN apk add --no-cache build-base sqlite-dev
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download
COPY --from=templ /app /app
RUN --mount=type=cache,target=/root/.cache/go-build \
    --mount=type=cache,target=/go/pkg/mod \
    CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o bin/tashirka cmd/tashirka/main.go

FROM alpine:3.23 AS run
RUN apk add --no-cache ca-certificates curl gzip
WORKDIR /app
COPY --from=builder /app/bin/tashirka /app/bin/tashirka
COPY --from=builder /app/static /app/static
COPY --from=builder /app/migrations /app/migrations
RUN find /app/static -type f -exec gzip -kf {} \; && \
    apk del gzip
RUN adduser --disabled-password --gecos "" noroot && \
    chown -R noroot:noroot /app
USER noroot:noroot
EXPOSE 8000
CMD ["/app/bin/tashirka"]

Ключевые моменты:

  • templ генерирует Go-код из .templ на отдельной стадии — в рантайме он не нужен.
  • CGO_ENABLED=1 обязателен: драйвер SQLite (mattn/go-sqlite3) — это C-код, поэтому в builder ставится build-base sqlite-dev.
  • -ldflags="-s -w" режет debug-информацию — бинарник заметно худеет.
  • Кэши BuildKit (--mount=type=cache) ускоряют пересборки: модули и кэш компилятора не качаются заново.
  • Статика gzip'уется на этапе сборки, потом gzip удаляется из образа.
  • Приложение работает от непривилегированного пользователя noroot.

compose.yml: app + Caddy

x-logging-template: &default-logging
  logging:
    driver: "json-file"
    options:
      max-size: "10m"
      max-file: "3"

volumes:
  caddy_data:
    driver: local
  caddy_config:
    driver: local

services:
  app:
    <<: *default-logging
    build:
      dockerfile: ./Dockerfile
    restart: always
    env_file: .env
    volumes:
      - "./static:/app/static"
      - ".env:/app/.env"
      - "./content:/app/content"
      - "./db:/app/db"
    healthcheck:
      test:
        [
          "CMD-SHELL",
          "curl -s http://localhost:8000/health | grep -q 'ok' || exit 1",
        ]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 15s

  caddy:
    <<: *default-logging
    restart: always
    image: caddy:2.11
    extra_hosts:
      - "host.docker.internal:host-gateway"
    ports:
      - "80:80/tcp"
      - "443:443/tcp"
      - "443:443/udp"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
      - "./static:/var/www/public:ro"

Что важно:

  • Контент, статика и база монтируются с хоста — посты правятся без пересборки образа.
  • Healthcheck дергает /health: оркестратор видит реальное состояние приложения, а не просто запущенный процесс.
  • Логи ограничены через YAML-якорь x-logging-template (max-size: 10m, 3 файла) и подключаются в app и caddy через <<: *default-logging — без лимита json-file съест диск.
  • Именованные тома caddy_data/caddy_config хранят TLS-сертификаты — без них Caddy будет перезапрашивать сертификаты при каждом пересоздании контейнера и упрется в rate-limit Let's Encrypt.
  • extra_hosts с host.docker.internal:host-gateway дает контейнерам доступ к сервисам на самом хосте (в моем случае — другие приложения на 8001-9000 портах).
  • Статика монтируется в Caddy read-only (:ro) — контейнер не может ее изменить.
  • restart: always поднимает оба сервиса после падения и перезагрузки сервера.
  • Caddy сам получает и продлевает TLS-сертификаты — certbot не нужен.

main.go: запуск и graceful shutdown

Ядро приложения — Echo-сервер на :8000 с корректным завершением по SIGTERM (важно для docker compose down и рестартов):

func main() {
	if err := Run(); err != nil {
		slog.Error("fatal", "error", err)
		os.Exit(1)
	}
}

func Run() error {
	database, err := db.NewDB(os.Getenv("DB_NAME"))
	if err != nil {
		return fmt.Errorf("failed to open database: %w", err)
	}
	defer database.Close()

	e := echo.New()
	e.HideBanner = true
	e.GET("/health", health.Handler(database))

	// регистрация хендлеров сайта и лид-формы...

	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	go func() {
		if err := e.Start(":8000"); err != nil && err != http.ErrServerClosed {
			stop()
		}
	}()

	<-ctx.Done()
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	return e.Shutdown(shutdownCtx)
}

Caddyfile: один сайт

Минимальный конфиг для основного домена: статику Caddy отдает сам из тома, остальное проксирует в Go. TLS включается автоматически — достаточно имени домена:

localhost, tashirka.ru {
    encode gzip

    header -Server
    header -Via

    route {
        # статика — file_server напрямую
        handle_path /static/* {
            root * /var/www/public
            file_server {
                precompressed gzip
            }
        }

        # всё остальное — reverse_proxy на Go
        handle {
            reverse_proxy app:8000
        }
    }
}
  • precompressed отдает готовые .gz-файлы, которые мы создали в Dockerfile, — CPU не тратится на лету.
  • handle_path + том ./static:/var/www/public — статика идет мимо Go-приложения.
  • Заголовки -Server, -Via убирают упоминания Caddy из ответа.

Makefile: одна точка входа

up:
	@docker compose -p tashirka up -d --remove-orphans --force-recreate

down:
	@docker compose -p tashirka down

build:
	@docker compose -p tashirka build

build-bin:
	@go tool templ generate && go build -ldflags="-s -w" -o bin/tashirka cmd/tashirka/main.go

check:
	@go tool templ generate && go fmt ./... && golangci-lint run ./... && go test ./...

dev:
	@go tool air

Логика простая:

  • make dev — локальная разработка с live-reload через air.
  • make check — линтер + тесты, запускается перед каждым коммитом.
  • make up — деплой на сервере: пересоздает только изменившиеся контейнеры.

Проверка

make up                                     # собрать и поднять
docker ps                                   # оба контейнера Up (healthy)
curl -s localhost/health               # ok
docker logs tashirka-app-1                  # логи приложения

Важно про безопасность

Порты 80/443 слушает только Caddy, само приложение наружу не торчит. .env монтируется в контейнер файлом, но не копируется в образ — секреты не попадают в слои.