From 5b9226569f100cf3bbb2cec6a6b5a042c09a4dc5 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 27 May 2026 14:40:35 -0300 Subject: [PATCH] feat(docker): publish multi-arch image to GHCR and add self-host compose Add a release workflow that builds docker/Dockerfile (production target) for linux/amd64 and linux/arm64 on native runners and publishes a single multi-arch manifest to ghcr.io/trypostit/trypost on every v*.*.* tag, tagged with semver (1.2.3, 1.2, 1) and latest. Add compose.prod.yaml: a self-host stack that pulls the published image alongside Postgres and Redis with persistent volumes. Config is inline with sectioned comments, including a commented S3/R2 block (storage already uses the default disk, so switching is config-only). No application code is changed. --- .github/workflows/release-docker.yml | 128 +++++++++++++++++++++++ compose.prod.yaml | 147 +++++++++++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 .github/workflows/release-docker.yml create mode 100644 compose.prod.yaml diff --git a/.github/workflows/release-docker.yml b/.github/workflows/release-docker.yml new file mode 100644 index 00000000..2bd275ae --- /dev/null +++ b/.github/workflows/release-docker.yml @@ -0,0 +1,128 @@ +name: Publish Docker image + +# Builds the production image and publishes it to GitHub Container Registry +# (GHCR) on every version tag. amd64 and arm64 are built on native runners, +# then stitched into one multi-arch manifest — no QEMU emulation. + +on: + push: + tags: + - 'v*.*.*' + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + name: Build (${{ matrix.platform }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + permissions: + contents: read + packages: write + steps: + - name: Prepare platform slug + run: echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV" + env: + platform: ${{ matrix.platform }} + + - name: Checkout + uses: actions/checkout@v4 + + - name: Docker metadata (labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + target: production + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ env.PLATFORM_PAIR }} + cache-to: type=gha,mode=max,scope=${{ env.PLATFORM_PAIR }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + run: | + mkdir -p "${{ runner.temp }}/digests" + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + name: Merge multi-arch manifest + runs-on: ubuntu-latest + needs: [build] + permissions: + contents: read + packages: write + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Docker metadata (tags) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create and push manifest list + working-directory: ${{ runner.temp }}/digests + run: | + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *) + + - name: Inspect published image + run: docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} diff --git a/compose.prod.yaml b/compose.prod.yaml new file mode 100644 index 00000000..e407888e --- /dev/null +++ b/compose.prod.yaml @@ -0,0 +1,147 @@ +# TryPost — self-hosted production stack. +# +# 1. Generate an app key: docker compose -f compose.prod.yaml run --rm app php artisan key:generate --show +# Paste the value into APP_KEY below. +# 2. Edit APP_URL and the passwords marked "change me". +# 3. Start: docker compose -f compose.prod.yaml up -d +# +# This pulls the published image — no local build. Postgres, Redis, the queue +# workers, the scheduler and the WebSocket server all run for you. + +services: + app: + image: ghcr.io/trypostit/trypost:latest + container_name: trypost + restart: unless-stopped + environment: + # ===== Required ===== + APP_NAME: TryPost + APP_ENV: production + APP_DEBUG: "false" + APP_KEY: "" # <- run key:generate (see header) and paste here + APP_URL: http://localhost:8000 # <- your public URL, e.g. https://post.yourdomain.com + SELF_HOSTED: "true" + TRYPOST_TARGET: production + + # ===== Database (bundled postgres service below) ===== + DB_CONNECTION: pgsql + DB_HOST: pgsql + DB_PORT: "5432" + DB_DATABASE: trypost + DB_USERNAME: trypost + DB_PASSWORD: trypost-password # <- change me (must match POSTGRES_PASSWORD below) + + # ===== Redis / queue / cache / broadcasting ===== + REDIS_HOST: redis + REDIS_PORT: "6379" + QUEUE_CONNECTION: redis + CACHE_STORE: redis + SESSION_DRIVER: database + BROADCAST_CONNECTION: reverb + + # ===== WebSockets (Reverb) ===== + REVERB_APP_ID: "1001" + REVERB_APP_KEY: trypost-reverb-key + REVERB_APP_SECRET: change-me-reverb-secret # <- change me + REVERB_HOST: localhost # <- for a real domain, set to that host (and PORT 443 / SCHEME https) + REVERB_PORT: "8080" + REVERB_SCHEME: http + + # ===== Storage ===== + # Default: local disk, persisted in the "storage" volume below. + FILESYSTEM_DISK: public + # + # --- Cloudflare R2: set FILESYSTEM_DISK=r2 and fill these --- + # R2_ACCESS_KEY_ID: "" + # R2_SECRET_ACCESS_KEY: "" + # R2_ENDPOINT: "" + # R2_BUCKET: "" + # R2_URL: "" # public bucket URL (needed for media to display) + # + # --- AWS S3: set FILESYSTEM_DISK=s3 and fill these --- + # AWS_ACCESS_KEY_ID: "" + # AWS_SECRET_ACCESS_KEY: "" + # AWS_DEFAULT_REGION: us-east-1 + # AWS_BUCKET: "" + # AWS_URL: "" + + # ===== Mail ===== + # Defaults to "log" (emails written to the container log). Configure SMTP + # for real password-reset / team-invite emails. + MAIL_MAILER: log + MAIL_FROM_ADDRESS: hello@example.com + MAIL_FROM_NAME: TryPost + # MAIL_MAILER: smtp + # MAIL_HOST: "" + # MAIL_PORT: "587" + # MAIL_USERNAME: "" + # MAIL_PASSWORD: "" + # MAIL_SCHEME: tls + + # ===== Social platforms (fill in when you connect each network) ===== + # Redirect URI in each portal: ${APP_URL}/accounts//callback + # LINKEDIN_CLIENT_ID: "" + # LINKEDIN_CLIENT_SECRET: "" + # X_CLIENT_ID: "" + # X_CLIENT_SECRET: "" + # FACEBOOK_CLIENT_ID: "" + # FACEBOOK_CLIENT_SECRET: "" + # INSTAGRAM_CLIENT_ID: "" + # INSTAGRAM_CLIENT_SECRET: "" + # THREADS_CLIENT_ID: "" + # THREADS_CLIENT_SECRET: "" + # TIKTOK_CLIENT_ID: "" + # TIKTOK_CLIENT_SECRET: "" + # PINTEREST_CLIENT_ID: "" + # PINTEREST_CLIENT_SECRET: "" + # GOOGLE_CLIENT_ID: "" # YouTube + Google login + # GOOGLE_CLIENT_SECRET: "" + + # ===== AI (optional — leave blank to disable AI features) ===== + # OPENAI_API_KEY: "" + # ANTHROPIC_API_KEY: "" + # GEMINI_API_KEY: "" + ports: + - "8000:80" # app (nginx) + - "8080:8080" # Reverb WebSocket + volumes: + - storage:/var/www/html/storage/app + depends_on: + pgsql: + condition: service_healthy + redis: + condition: service_healthy + + pgsql: + image: postgres:16-alpine + container_name: trypost-pgsql + restart: unless-stopped + environment: + POSTGRES_DB: trypost + POSTGRES_USER: trypost + POSTGRES_PASSWORD: trypost-password # <- must match DB_PASSWORD above + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U trypost -d trypost'] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: trypost-redis + restart: unless-stopped + command: redis-server --appendonly yes + volumes: + - redisdata:/data + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 10s + timeout: 3s + retries: 5 + +volumes: + pgdata: + redisdata: + storage: