# ============================================================================= # jasonwoltje.com — Docker Swarm / Portainer stack # ============================================================================= # # Deploy target: w-docker0 (10.1.1.45), Portainer endpoint 7. # Routing: # jasonwoltje.com / www.jasonwoltje.com -> web (Next.js + Payload CMS) # # Ingress pattern (mirrors MosaicStack): # Edge Traefik (10.1.1.43) terminates TLS # -> per-swarm Traefik on w-docker0 on entrypoint "web" (HTTP) # -> web:3000 # # Usage (Portainer): # Stacks -> Add Stack -> Git repository # URL: https://git.mosaicstack.dev/jason.woltje/professional-website # Compose path: docker-compose.swarm.yml # Env vars: see .env.example (all required unless marked optional) # Deploy # # Image tag rule: WEB_IMAGE_TAG MUST be an immutable tag (sha-<8> or vX.Y.Z). # Never point this stack at `latest`. # ============================================================================= services: web: image: git.mosaicstack.dev/jason.woltje/professional-website:${WEB_IMAGE_TAG} environment: DATABASE_URI: postgresql://${PAYLOAD_POSTGRES_USER}:${PAYLOAD_POSTGRES_PASSWORD}@jasonwoltje_postgres:5432/${PAYLOAD_POSTGRES_DB} PAYLOAD_SECRET: ${PAYLOAD_SECRET} PAYLOAD_PUBLIC_SERVER_URL: https://${SITE_DOMAIN:-jasonwoltje.com} NEXT_PUBLIC_SITE_URL: https://${SITE_DOMAIN:-jasonwoltje.com} NEXT_PUBLIC_BUILD_SHA: ${WEB_IMAGE_TAG} NEXT_PUBLIC_BUILD_REV: ${WEB_IMAGE_TAG} NEXT_PUBLIC_TURNSTILE_SITE_KEY: ${NEXT_PUBLIC_TURNSTILE_SITE_KEY:-} TURNSTILE_SECRET_KEY: ${TURNSTILE_SECRET_KEY:-} NEXT_PUBLIC_UMAMI_SRC: ${NEXT_PUBLIC_UMAMI_SRC:-} NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID:-} RESEND_API_KEY: ${RESEND_API_KEY:-} RESEND_FROM: ${RESEND_FROM:-no-reply@jasonwoltje.com} RESEND_TO: ${RESEND_TO:-jason@diversecanvas.com} MAUTIC_FORM_URL: ${MAUTIC_FORM_URL:-} volumes: - media:/app/media healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/api/health || exit 1"] interval: 30s timeout: 10s retries: 3 start_period: 60s networks: - internal - traefik-public deploy: replicas: 1 update_config: parallelism: 1 delay: 30s order: start-first restart_policy: condition: on-failure delay: 5s max_attempts: 5 window: 120s labels: - "traefik.enable=true" - "traefik.http.routers.jasonwoltje.rule=Host(`${SITE_DOMAIN:-jasonwoltje.com}`) || Host(`www.${SITE_DOMAIN:-jasonwoltje.com}`)" - "traefik.http.routers.jasonwoltje.entrypoints=web" - "traefik.http.services.jasonwoltje.loadbalancer.server.port=3000" # www -> apex 301 - "traefik.http.middlewares.jasonwoltje-www-redirect.redirectregex.regex=^https?://www\\.${SITE_DOMAIN:-jasonwoltje.com}/(.*)" - "traefik.http.middlewares.jasonwoltje-www-redirect.redirectregex.replacement=https://${SITE_DOMAIN:-jasonwoltje.com}/$${1}" - "traefik.http.middlewares.jasonwoltje-www-redirect.redirectregex.permanent=true" - "traefik.http.routers.jasonwoltje.middlewares=jasonwoltje-www-redirect" postgres: image: postgres:17-alpine environment: POSTGRES_DB: ${PAYLOAD_POSTGRES_DB:-payload} POSTGRES_USER: ${PAYLOAD_POSTGRES_USER:-payload} POSTGRES_PASSWORD: ${PAYLOAD_POSTGRES_PASSWORD} volumes: - postgres-data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"] interval: 10s timeout: 5s retries: 5 start_period: 30s networks: - internal deploy: replicas: 1 restart_policy: condition: on-failure delay: 5s max_attempts: 5 placement: constraints: - node.role == manager volumes: postgres-data: media: networks: internal: driver: overlay traefik-public: external: true