Usare Strapi in un’applicazione web con Go usando Docker e Docker Compose

Questo articolo mostra un flusso completo e ripetibile per integrare Strapi (headless CMS) in una applicazione web con backend in Go, orchestrando tutto con Docker e Docker Compose. L’obiettivo è ottenere:

  • Un’istanza Strapi in container, con database persistente (PostgreSQL).
  • Un servizio Go in container che legge i contenuti da Strapi tramite REST API.
  • Un setup locale consistente per sviluppo e test, facilmente estendibile a staging/produzione.

Prerequisiti

  • Docker e Docker Compose installati.
  • Conoscenze base di Go e HTTP/JSON.
  • Una macchina con almeno 4 GB di RAM disponibili per Docker (consigliato 6–8 GB).

Architettura di riferimento

Nel setup che costruiremo:

  • strapi: container Strapi esposto su http://localhost:1337.
  • db: PostgreSQL con volume per persistenza.
  • api: servizio Go esposto su http://localhost:8080.

Il servizio Go non “monta” Strapi dentro al proprio processo: lo consuma come un servizio HTTP esterno. Questo rende l’integrazione pulita e mantiene la separazione tra gestione contenuti (Strapi) e logica applicativa (Go).

Struttura del progetto

Una struttura minimale (puoi adattarla alle tue preferenze):

.
├── compose.yaml
├── strapi
│   ├── Dockerfile
│   └── .env
└── api
    ├── Dockerfile
    ├── go.mod
    ├── go.sum
    ├── main.go
    └── .env

1) Containerizzare Strapi

File strapi/Dockerfile

Qui creiamo una build basata su Node LTS e installiamo le dipendenze del progetto Strapi. In sviluppo è comune montare il codice come volume; in produzione useresti un’immagine più “chiusa” e ottimizzata.

FROM node:20-bookworm-slim

WORKDIR /app

# Dipendenze necessarie a Strapi e ad alcuni provider (es. sharp)
RUN apt-get update && apt-get install -y --no-install-recommends \
  build-essential python3 pkg-config libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev \
  && rm -rf /var/lib/apt/lists/*

# Copiamo solo package* per sfruttare la cache
COPY package.json package-lock.json* ./

RUN npm ci

# Copiamo il resto
COPY . .

EXPOSE 1337

CMD ["npm", "run", "develop"]

Nota: se inizializzi Strapi con npx create-strapi-app, avrai già package.json ecc. Se preferisci generare Strapi direttamente nel container, è possibile, ma per un progetto reale conviene versionare il codice Strapi nel repository.

File strapi/.env

Variabili tipiche per collegare Strapi a PostgreSQL e impostare chiavi applicative. In locale puoi usare valori di esempio; in ambienti reali usa segreti robusti.

# Strapi
HOST=0.0.0.0
PORT=1337
APP_KEYS=change-me-1,change-me-2,change-me-3,change-me-4
API_TOKEN_SALT=change-me-token-salt
ADMIN_JWT_SECRET=change-me-admin-jwt
JWT_SECRET=change-me-jwt

# Database (PostgreSQL)
DATABASE_CLIENT=postgres
DATABASE_HOST=db
DATABASE_PORT=5432
DATABASE_NAME=strapi
DATABASE_USERNAME=strapi
DATABASE_PASSWORD=strapi
DATABASE_SSL=false

2) Servizio Go: consumare Strapi via REST

Il servizio Go esporrà un endpoint, ad esempio /posts, che interroga Strapi (collection type “Post”) e restituisce una vista “pulita” al client. Questo pattern è utile quando vuoi:

  • Un layer di cache, business rules o aggregazioni non gestite da Strapi.
  • Uniformare la risposta per il frontend o per altri consumatori.
  • Gestire autenticazione/permessi in modo centralizzato.

File api/go.mod

module example.com/strapi-go-docker

go 1.22

File api/main.go

Questo esempio usa la REST API di Strapi. Strapi restituisce strutture JSON con data, attributes ecc. La forma esatta dipende dalla versione e dai tipi creati; qui gestiamo un caso tipico: una collezione “posts” con campi title, slug, content, e publishedAt.

package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"time"
)

type Config struct {
	ListenAddr  string
	StrapiURL   string
	StrapiToken string
	HTTPTimeout time.Duration
}

func loadConfig() Config {
	timeout := 8 * time.Second
	if v := os.Getenv("HTTP_TIMEOUT"); v != "" {
		if d, err := time.ParseDuration(v); err == nil {
			timeout = d
		}
	}

	return Config{
		ListenAddr:  getenvDefault("LISTEN_ADDR", ":8080"),
		StrapiURL:   getenvDefault("STRAPI_URL", "http://strapi:1337"),
		StrapiToken: os.Getenv("STRAPI_API_TOKEN"),
		HTTPTimeout: timeout,
	}
}

func getenvDefault(key, def string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return def
}

type StrapiListResponse[T any] struct {
	Data []struct {
		ID         int `json:"id"`
		Attributes T   `json:"attributes"`
	} `json:"data"`
	Meta any `json:"meta"`
}

type PostAttributes struct {
	Title       string    `json:"title"`
	Slug        string    `json:"slug"`
	Content     string    `json:"content"`
	PublishedAt time.Time `json:"publishedAt"`
}

type PostDTO struct {
	ID          int       `json:"id"`
	Title       string    `json:"title"`
	Slug        string    `json:"slug"`
	Content     string    `json:"content"`
	PublishedAt time.Time `json:"publishedAt"`
}

func main() {
	cfg := loadConfig()

	mux := http.NewServeMux()
	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("ok"))
	})

	mux.HandleFunc("/posts", func(w http.ResponseWriter, r *http.Request) {
		posts, err := fetchPosts(r.Context(), cfg)
		if err != nil {
			log.Printf("fetch posts error: %v", err)
			http.Error(w, "upstream error", http.StatusBadGateway)
			return
		}
		writeJSON(w, posts)
	})

	srv := &http.Server{
		Addr:              cfg.ListenAddr,
		Handler:           withBasicMiddleware(mux),
		ReadHeaderTimeout: 5 * time.Second,
	}

	log.Printf("Go API listening on %s", cfg.ListenAddr)
	if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		log.Fatal(err)
	}
}

func withBasicMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		w.Header().Set("X-Content-Type-Options", "nosniff")
		next.ServeHTTP(w, r)
	})
}

func writeJSON(w http.ResponseWriter, v any) {
	enc := json.NewEncoder(w)
	enc.SetEscapeHTML(true)
	if err := enc.Encode(v); err != nil {
		http.Error(w, "encode error", http.StatusInternalServerError)
	}
}

func fetchPosts(ctx context.Context, cfg Config) ([]PostDTO, error) {
	// Esempio: /api/posts?sort=publishedAt:desc&pagination[pageSize]=20
	url := fmt.Sprintf("%s/api/posts?sort=publishedAt:desc&pagination[pageSize]=20", cfg.StrapiURL)

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}

	// Se usi Token API di Strapi: Authorization: Bearer 
	if cfg.StrapiToken != "" {
		req.Header.Set("Authorization", "Bearer "+cfg.StrapiToken)
	}

	c := &http.Client{Timeout: cfg.HTTPTimeout}
	resp, err := c.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return nil, fmt.Errorf("strapi returned %s", resp.Status)
	}

	var raw StrapiListResponse[PostAttributes]
	if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
		return nil, err
	}

	out := make([]PostDTO, 0, len(raw.Data))
	for _, item := range raw.Data {
		out = append(out, PostDTO{
			ID:          item.ID,
			Title:       item.Attributes.Title,
			Slug:        item.Attributes.Slug,
			Content:     item.Attributes.Content,
			PublishedAt: item.Attributes.PublishedAt,
		})
	}
	return out, nil
}

File api/.env

Qui configuriamo il servizio Go con l’URL interno di Strapi. In Compose, i servizi si risolvono via DNS sul nome del servizio (strapi), quindi http://strapi:1337 è corretto dal container Go.

LISTEN_ADDR=:8080
STRAPI_URL=http://strapi:1337
HTTP_TIMEOUT=8s

# Se usi un token creato su Strapi (Settings > API Tokens)
# STRAPI_API_TOKEN=...

File api/Dockerfile

Dockerfile multi-stage: compila il binario statico e lo copia in una immagine minimale.

FROM golang:1.22-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/api ./...

FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/api /app/api
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/app/api"]

3) Docker Compose: Strapi + Postgres + Go

Creiamo un file compose.yaml nella root. Questo orchestri i servizi, definisce volumi persistenti e passa le variabili d’ambiente.

name: strapi-go

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_DB: strapi
      POSTGRES_USER: strapi
      POSTGRES_PASSWORD: strapi
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U strapi -d strapi"]
      interval: 5s
      timeout: 5s
      retries: 20

  strapi:
    build:
      context: ./strapi
    env_file:
      - ./strapi/.env
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "1337:1337"
    volumes:
      - ./strapi:/app
      - /app/node_modules
    healthcheck:
      test: ["CMD-SHELL", "node -e \"fetch('http://localhost:1337/admin').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
      interval: 10s
      timeout: 5s
      retries: 30

  api:
    build:
      context: ./api
    env_file:
      - ./api/.env
    depends_on:
      strapi:
        condition: service_healthy
    ports:
      - "8080:8080"
    restart: unless-stopped

volumes:
  db_data:

Se preferisci non montare il codice Strapi (approccio più simile alla produzione), puoi rimuovere il volume ./strapi:/app e costruire un’immagine con l’app Strapi già dentro. In sviluppo però il bind mount accelera molto il ciclo di feedback.

4) Inizializzare il progetto Strapi

Se non hai ancora una cartella Strapi pronta, puoi crearla una tantum. Un approccio pratico è generare Strapi localmente e poi usarlo con Compose:

# Nella root del progetto
npx create-strapi-app@latest strapi --quickstart

Poi modifica la configurazione del database per usare PostgreSQL, oppure imposta le variabili in strapi/.env come mostrato prima. In Strapi, la configurazione DB dipende dalla versione; per Strapi v4 tipicamente si lavora in config/database.js e si legge da process.env.

Esempio di strapi/config/database.js (Strapi v4) che usa le variabili d’ambiente:

module.exports = ({ env }) => ({
  connection: {
    client: 'postgres',
    connection: {
      host: env('DATABASE_HOST', 'localhost'),
      port: env.int('DATABASE_PORT', 5432),
      database: env('DATABASE_NAME', 'strapi'),
      user: env('DATABASE_USERNAME', 'strapi'),
      password: env('DATABASE_PASSWORD', 'strapi'),
      ssl: env.bool('DATABASE_SSL', false),
    },
    debug: false,
  },
});

Se la tua versione di Strapi differisce (v5 o successiva), i file e le opzioni possono cambiare leggermente. Il principio rimane: “DB config da env” e “host/port in container”.

5) Avvio dell’ambiente

docker compose up --build

Dovresti ottenere:

  • Admin Strapi su http://localhost:1337/admin
  • API Go su http://localhost:8080/posts

Al primo avvio Strapi chiederà di creare l’utente admin. Dopo l’onboarding, crea un Collection Type chiamato ad esempio Post con i campi:

  • title (Text)
  • slug (UID basato su title)
  • content (Rich text o Text)
  • publishedAt (gestito automaticamente se abiliti “Draft & Publish”)

6) Permessi e sicurezza: Public, API Token e ruoli

In Strapi, le API possono essere protette in più modi. Per sviluppo è comodo abilitare l’accesso pubblico in: Settings → Users & Permissions plugin → Roles → Public. In produzione invece è spesso meglio:

  • Disabilitare o limitare il ruolo Public.
  • Usare API Tokens (Settings → API Tokens) e inviarli dal backend Go.
  • Gestire autenticazione utente con il plugin Users & Permissions e chiamare Strapi con JWT utente, se serve.

Nel codice Go, l’header Authorization: Bearer ... viene aggiunto se STRAPI_API_TOKEN è presente. Questo è un buon punto di partenza per evitare che il frontend esponga token o segreti.

7) Popolare relazioni e media

Strapi per default non “popola” relazioni e media nella REST API; serve la query populate. Esempio, se un Post ha un’immagine “cover” (media field) e un autore (relation):

# Esempio REST
GET /api/posts?populate[cover]=*&populate[author]=*

Dal lato Go, puoi ampliare i tipi JSON oppure decodificare in mappe generiche se vuoi flessibilità. Per progetti grandi conviene modellare i payload e isolare la logica di trasformazione in un package dedicato.

8) Caching e resilienza nel servizio Go

Una volta che Go fa da “facciata” tra client e Strapi, puoi aggiungere:

  • Cache in memoria con TTL per ridurre chiamate a Strapi (utile per pagine pubbliche).
  • Timeout e retry per gestire errori transitori.
  • Circuit breaker se Strapi è instabile.

Anche senza introdurre librerie, un timeout sul client HTTP (già presente) evita request bloccate. Per la cache puoi iniziare con una struttura semplice e poi passare a Redis quando serve.

9) Migliorare lo sviluppo: hot reload e logs

Per Strapi, npm run develop già supporta un ciclo di sviluppo rapido. Per Go puoi usare strumenti di hot reload (ad esempio air) in un container dedicato o in locale, mantenendo Strapi e DB in Compose.

Esempio di workflow ibrido molto pratico:

  • Avvia in Docker: db e strapi.
  • Esegui Go in locale (o con hot reload) puntando a http://localhost:1337.

In Compose, puoi separare profili (es. profiles: ["dev"]) per avere varianti “solo infrastruttura” e “tutto in container”.

10) Deployment: considerazioni essenziali

Per produzione, tipicamente conviene:

  • Usare un database gestito o un volume affidabile (PostgreSQL con backup e retention).
  • Costruire un’immagine Strapi “immutabile” (senza bind mount) e avviare con npm start.
  • Impostare correttamente NODE_ENV=production e le variabili di sicurezza (segreti reali).
  • Mettere un reverse proxy (Nginx/Traefik) con TLS davanti a Strapi e/o Go.
  • Limitare l’admin Strapi a rete interna o VPN, se possibile.

In ambienti orchestrati (Kubernetes), Strapi e Go diventano deployment separati, con ConfigMap/Secret e un servizio DB esterno. Tuttavia, la logica e i concetti restano identici a Compose.

Checklist rapida di troubleshooting

Problema Possibile causa Soluzione
Strapi non parte e va in loop DB non raggiungibile o migrazioni fallite Controlla log di db e strapi, verifica variabili DB, elimina volume solo se accetti perdita dati
Go riceve 403/401 da Strapi Permessi Public disabilitati o token mancante/errato Crea un API Token e imposta STRAPI_API_TOKEN, oppure abilita temporaneamente i permessi Public
Campi relazionali vuoti Manca populate nella query Aggiungi populate per relazioni/media
Modifiche Strapi non si riflettono Cache, build non ricostruita, volumi In sviluppo usa bind mount; se necessario docker compose up --build e controlla /app/node_modules

Conclusione

Con Strapi in container e un servizio Go che consuma i contenuti via REST, ottieni un’architettura modulare: il team contenuti lavora nell’admin Strapi, mentre l’applicazione Go applica logica, sicurezza e presentazione. Docker Compose rende l’ambiente riproducibile e riduce le differenze tra macchine di sviluppo.

Da qui puoi estendere il progetto con GraphQL (Strapi), caching Redis, code generation per i client, oppure con un frontend SSR/SPA che consuma l’API Go.

Torna su