diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b165072 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Copy to .env on each host and fill in real values — .env is gitignored, +# never commit real secrets. +# +# Local dev machine: keep COMPOSE_FILE=docker-compose.dev.yaml (exposes +# ports directly, hot-reloads source via bind mounts, includes adminer). +# +# Prod host: leave COMPOSE_FILE unset (docker-compose.yaml is Compose's +# default filename) and set the rest to real values. + +# COMPOSE_FILE=docker-compose.dev.yaml + +# Domain Traefik routes to this app (bare domain — the compose file's +# redirect middleware sends it to the www. subdomain). +VIRTUAL_HOST=example.com + +# Postgres credentials — must be a real secret in prod (e.g. `openssl rand +# -hex 32`), and must match the user/password embedded in DATABASE_URL below. +POSTGRES_USER=postgres +POSTGRES_PASSWORD= +POSTGRES_DB=postgres +DATABASE_URL=postgres://postgres:REPLACE_WITH_POSTGRES_PASSWORD@db:5432/postgres?sslmode=disable + +# Open Data Hub license key used by the Go importer. +ODH_LICENCE_KEY= diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..5927e0c --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,4 @@ +# Downloaded event media (18GB+) — runtime data written by the importer, +# never needed at build time, and provided via a volume mount in compose. +public/ +.git diff --git a/backend/Dockerfile.prod b/backend/Dockerfile.prod new file mode 100644 index 0000000..f9568d0 --- /dev/null +++ b/backend/Dockerfile.prod @@ -0,0 +1,26 @@ +# ---- build ---- +FROM golang:1.24 AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/vindn-backend ./cmd/server + +# ---- runtime ---- +# "static" (not "-nonroot") to match the existing root:root ownership of the +# bind-mounted ./backend/public/storage host directory shared with the +# frontend container — switching to a non-root UID here would need that +# directory rechowned too, or every media download starts failing silently. +FROM gcr.io/distroless/static-debian12 + +# The imported event data and the site's audience are both German-local; +# default to that instead of UTC so the twice-daily cron (see tasks/Cronjob.go) +# actually fires at 06:00/18:00 German time, not UTC. distroless/static ships +# tzdata, but time/tzdata is also embedded in the binary above for safety. +ENV TZ=Europe/Berlin + +WORKDIR /app +COPY --from=build /out/vindn-backend ./vindn-backend + +EXPOSE 8080 +ENTRYPOINT ["./vindn-backend"] diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 4ebf186..78edef8 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -2,6 +2,8 @@ package main import ( "log" + "net/http" + "os" "whimsoc/vindn-backend/config" "whimsoc/vindn-backend/models" "whimsoc/vindn-backend/repositories" @@ -9,9 +11,23 @@ import ( "whimsoc/vindn-backend/tasks" "github.com/gofiber/fiber/v2" + + // Embeds the IANA timezone database into the binary so TZ=Europe/Berlin + // (see docker-compose.yaml) resolves correctly even on a minimal runtime + // image that might not ship /usr/share/zoneinfo — the twice-daily cron + // schedule in tasks/Cronjob.go depends on this being right. + _ "time/tzdata" ) func main() { + // The prod runtime image is distroless (no shell, no wget/curl), so + // Docker's HEALTHCHECK runs this binary in a second mode instead of a + // separate tool — see backend/Dockerfile.prod. + if len(os.Args) > 1 && os.Args[1] == "healthcheck" { + runHealthcheck() + return + } + // Lade .env config.LoadEnv() @@ -44,3 +60,10 @@ func main() { log.Fatal(app.Listen(":" + config.GetEnv("PORT", "8080"))) } + +func runHealthcheck() { + resp, err := http.Get("http://localhost:" + config.GetEnv("PORT", "8080") + "/health") + if err != nil || resp.StatusCode != http.StatusOK { + os.Exit(1) + } +} diff --git a/docker-compose.yaml b/docker-compose.yaml index 4785934..3b0b233 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,42 +1,70 @@ services: backend: - image: golang:1.24 - working_dir: /backend - + build: + context: ./backend + dockerfile: Dockerfile.prod depends_on: - - db + db: + condition: service_healthy environment: - DATABASE_URL=${DATABASE_URL} - ODH_LICENCE_KEY=${ODH_LICENCE_KEY} - TZ=Europe/Berlin volumes: - - ./backend:/backend - command: go run cmd/server/main.go + # Working directory inside Dockerfile.prod is /app — must match the + # frontend's mount of the same host path below, since that's how + # downloaded media becomes visible to the frontend at all. + - ./backend/public/storage:/app/public/storage restart: unless-stopped + healthcheck: + # Distroless has no shell/wget/curl, so the binary checks itself — + # see the `healthcheck` subcommand in cmd/server/main.go. + test: ["CMD", "/app/vindn-backend", "healthcheck"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s db: image: postgres:14 environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: postgres + POSTGRES_USER: ${POSTGRES_USER:-postgres} + # No insecure default here on purpose — POSTGRES_PASSWORD must be set + # in this environment's own .env (matching the credentials embedded + # in DATABASE_URL), so a prod deploy fails loudly instead of quietly + # running on the well-known "postgres" password. + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in .env} + POSTGRES_DB: ${POSTGRES_DB:-postgres} # ports: # - "5432:5432" volumes: - ./pgdata:/var/lib/postgresql/data restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"] + interval: 10s + timeout: 5s + retries: 5 frontend: - build: ./frontend - working_dir: /app + build: + context: ./frontend + dockerfile: Dockerfile.prod depends_on: - - db + db: + condition: service_healthy environment: - DATABASE_URL=${DATABASE_URL} + - APP_ENV=prod + - APP_DEBUG=0 volumes: - - ./frontend:/app - ./backend/public/storage:/app/public/storage - command: sh -c "composer install --no-interaction && php -S 0.0.0.0:8000 -t public" restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s networks: - traefik - default @@ -50,7 +78,7 @@ services: - "traefik.http.routers.vindn.tls=true" - "traefik.http.routers.vindn.middlewares=www-redirect-reverse_vindn" - "traefik.http.middlewares.www-redirect-reverse_vindn.redirectregex.permanent=true" - - "traefik.http.middlewares.www-redirect-reverse_vindn.redirectregex.regex=^http[|s]://${VIRTUAL_HOST}/(.*)" + - "traefik.http.middlewares.www-redirect-reverse_vindn.redirectregex.regex=^https?://${VIRTUAL_HOST}/(.*)" - "traefik.http.middlewares.www-redirect-reverse_vindn.redirectregex.replacement=https://www.${VIRTUAL_HOST}/$${1}" networks: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..0c4bc42 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,6 @@ +# Dev-installed vendor/cache — the prod Dockerfile runs its own +# `composer install --no-dev` and cache:clear, so copying these in first +# would only bloat the build context and get overwritten anyway. +vendor/ +var/ +.git diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod new file mode 100644 index 0000000..41eb392 --- /dev/null +++ b/frontend/Dockerfile.prod @@ -0,0 +1,24 @@ +FROM dunglas/frankenphp:latest + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/ +RUN install-php-extensions pdo_pgsql zip + +# The imported event data and the site's audience are both German-local; +# default to that instead of UTC so any "now"/"today" computation doesn't +# quietly disagree with the data for the two hours after every UTC midnight. +ENV TZ=Europe/Berlin +RUN echo "date.timezone = Europe/Berlin" > $PHP_INI_DIR/conf.d/timezone.ini + +WORKDIR /app +COPY . . +RUN composer install --no-interaction --no-dev --optimize-autoloader \ + && php bin/console cache:clear --env=prod --no-debug + +# Caddy's default site address in the base image's Caddyfile is +# {$SERVER_NAME:localhost}; a bare ":8000" (no hostname) makes it listen +# HTTP-only on that port, which is what we want — Traefik terminates TLS +# and forwards plain HTTP to this container. +ENV SERVER_NAME=:8000 +EXPOSE 8000 diff --git a/frontend/templates/home.html.twig b/frontend/templates/home.html.twig index d1871c0..b706119 100644 --- a/frontend/templates/home.html.twig +++ b/frontend/templates/home.html.twig @@ -11,9 +11,9 @@
-

Nordrhein-Westfalen — updated every hour

+

-

What's on near you

+

Was in deiner Nähe los ist

Open full map →