Dockerfile, compose.yml и Makefile для Go-сайта
2026-08-25
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 монтируется в контейнер файлом, но не копируется в образ — секреты не попадают в слои.