From 97819d374e79a0d0ed9d84eb8c94eec9409b485a Mon Sep 17 00:00:00 2001 From: Daxsum Date: Sat, 12 Sep 2026 22:14:21 +0200 Subject: [PATCH 1/7] chore: adapt docker-compose for Coolify deployment Drop host port bindings and the ./api/.env file dependency (not present in the repo) in favor of Coolify-managed environment variables and its SERVICE_FQDN_*/SERVICE_URL_* magic vars for auto-generated domains. Pass the web app's Firebase/API config as Docker build args since the Nuxt app is generated statically and bakes process.env at build time. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01L5D2TYHpgXXXCrUJpcQYxg --- docker-compose.yml | 72 +++++++++++++++++++++++++++++++++++++--------- web/Dockerfile | 39 +++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 13 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index ef63287d2..bd61721a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,15 +3,13 @@ services: image: postgres:alpine environment: POSTGRES_DB: httpsms - POSTGRES_PASSWORD: dbpassword - POSTGRES_USER: dbusername + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_USER: ${POSTGRES_USER} volumes: - postgres:/var/lib/postgresql/data - ports: - - "5435:5432" restart: on-failure healthcheck: - test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d httpsms"] interval: 30s timeout: 60s retries: 5 @@ -22,31 +20,79 @@ services: command: redis-server volumes: - redis:/var/lib/redis - ports: - - "6379:6379" restart: on-failure api: build: context: ./api - ports: - - "8000:8000" depends_on: postgres: condition: service_healthy redis: condition: service_started - env_file: - - ./api/.env + restart: on-failure + environment: + SERVICE_FQDN_API_8000: + ENV: production + GCP_PROJECT_ID: ${GCP_PROJECT_ID} + USE_HTTP_LOGGER: "true" + ENTITLEMENT_ENABLED: "false" + EVENTS_QUEUE_TYPE: emulator + EVENTS_QUEUE_NAME: events-local + EVENTS_QUEUE_ENDPOINT: http://localhost:8000/v1/events + EVENTS_QUEUE_USER_API_KEY: ${EVENTS_QUEUE_USER_API_KEY} + EVENTS_QUEUE_USER_ID: ${EVENTS_QUEUE_USER_ID} + FIREBASE_CREDENTIALS: ${FIREBASE_CREDENTIALS} + SMTP_FROM_NAME: httpSMS + SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL} + SMTP_USERNAME: ${SMTP_USERNAME} + SMTP_PASSWORD: ${SMTP_PASSWORD} + SMTP_HOST: ${SMTP_HOST} + SMTP_PORT: ${SMTP_PORT} + APP_URL: ${SERVICE_URL_WEB_3000} + APP_NAME: httpSMS + APP_PORT: 8000 + SWAGGER_HOST: ${SERVICE_FQDN_API_8000} + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/httpsms + DATABASE_URL_DEDICATED: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/httpsms + REDIS_URL: redis://@redis:6379 + GCS_BUCKET_NAME: ${GCS_BUCKET_NAME} + AXIOM_TOKEN: ${AXIOM_TOKEN} + AXIOM_DATASET_EVENTS: ${AXIOM_DATASET_EVENTS} + AXIOM_DATASET_METRICS: ${AXIOM_DATASET_METRICS} + PUSHER_APP_ID: ${PUSHER_APP_ID} + PUSHER_KEY: ${PUSHER_KEY} + PUSHER_SECRET: ${PUSHER_SECRET} + PUSHER_CLUSTER: ${PUSHER_CLUSTER} + CLOUDFLARE_TURNSTILE_SECRET_KEY: ${CLOUDFLARE_TURNSTILE_SECRET_KEY} web: build: context: ./web - ports: - - "3000:3000" + args: + API_BASE_URL: ${SERVICE_URL_API_8000} + APP_URL: ${SERVICE_URL_WEB_3000} + APP_NAME: httpSMS + APP_GITHUB_URL: https://github.com/NdoleStudio/httpsms + APP_DOCUMENTATION_URL: https://docs.httpsms.com + APP_DOWNLOAD_URL: https://github.com/NdoleStudio/httpsms/releases/latest/download/HttpSms.apk + APP_ENV: production + CLOUDFLARE_TURNSTILE_SITE_KEY: ${CLOUDFLARE_TURNSTILE_SITE_KEY} + PUSHER_KEY: ${PUSHER_KEY} + PUSHER_CLUSTER: ${PUSHER_CLUSTER} + FIREBASE_API_KEY: ${FIREBASE_API_KEY} + FIREBASE_AUTH_DOMAIN: ${FIREBASE_AUTH_DOMAIN} + FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID} + FIREBASE_STORAGE_BUCKET: ${FIREBASE_STORAGE_BUCKET} + FIREBASE_MESSAGING_SENDER_ID: ${FIREBASE_MESSAGING_SENDER_ID} + FIREBASE_APP_ID: ${FIREBASE_APP_ID} + FIREBASE_MEASUREMENT_ID: ${FIREBASE_MEASUREMENT_ID} depends_on: api: condition: service_started + restart: on-failure + environment: + SERVICE_FQDN_WEB_3000: volumes: redis: diff --git a/web/Dockerfile b/web/Dockerfile index f283fd32a..545803cb6 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -11,6 +11,45 @@ RUN npm install -g pnpm RUN pnpm install COPY . . + +# These are read by nuxt.config.ts at generate-time and baked into the static +# output (the app ships as a client-rendered SPA), so they must be available +# as build args rather than runtime container env vars. +ARG API_BASE_URL +ARG APP_URL +ARG APP_NAME +ARG APP_GITHUB_URL +ARG APP_DOCUMENTATION_URL +ARG APP_DOWNLOAD_URL +ARG APP_ENV +ARG CLOUDFLARE_TURNSTILE_SITE_KEY +ARG PUSHER_KEY +ARG PUSHER_CLUSTER +ARG FIREBASE_API_KEY +ARG FIREBASE_AUTH_DOMAIN +ARG FIREBASE_PROJECT_ID +ARG FIREBASE_STORAGE_BUCKET +ARG FIREBASE_MESSAGING_SENDER_ID +ARG FIREBASE_APP_ID +ARG FIREBASE_MEASUREMENT_ID +ENV API_BASE_URL=$API_BASE_URL \ + APP_URL=$APP_URL \ + APP_NAME=$APP_NAME \ + APP_GITHUB_URL=$APP_GITHUB_URL \ + APP_DOCUMENTATION_URL=$APP_DOCUMENTATION_URL \ + APP_DOWNLOAD_URL=$APP_DOWNLOAD_URL \ + APP_ENV=$APP_ENV \ + CLOUDFLARE_TURNSTILE_SITE_KEY=$CLOUDFLARE_TURNSTILE_SITE_KEY \ + PUSHER_KEY=$PUSHER_KEY \ + PUSHER_CLUSTER=$PUSHER_CLUSTER \ + FIREBASE_API_KEY=$FIREBASE_API_KEY \ + FIREBASE_AUTH_DOMAIN=$FIREBASE_AUTH_DOMAIN \ + FIREBASE_PROJECT_ID=$FIREBASE_PROJECT_ID \ + FIREBASE_STORAGE_BUCKET=$FIREBASE_STORAGE_BUCKET \ + FIREBASE_MESSAGING_SENDER_ID=$FIREBASE_MESSAGING_SENDER_ID \ + FIREBASE_APP_ID=$FIREBASE_APP_ID \ + FIREBASE_MEASUREMENT_ID=$FIREBASE_MEASUREMENT_ID + RUN pnpm run generate # production stage From 6a1775a0e21e9968bb193d4412c718ec47e4feb9 Mon Sep 17 00:00:00 2001 From: Daxsum Date: Sat, 12 Sep 2026 23:26:09 +0200 Subject: [PATCH 2/7] fix(web): allow required build scripts under pnpm's ignored-builds gate pnpm now refuses native postinstall/build scripts for these deps by default (ERR_PNPM_IGNORED_BUILDS), breaking a plain `pnpm install` in CI/Docker where there's no TTY to run `pnpm approve-builds`. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01L5D2TYHpgXXXCrUJpcQYxg --- web/package.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/web/package.json b/web/package.json index bfec579c7..fc17ce137 100644 --- a/web/package.json +++ b/web/package.json @@ -22,6 +22,15 @@ "*.{css,scss,sass,vue}": "stylelint", "*": "prettier --check --ignore-unknown" }, + "pnpm": { + "onlyBuiltDependencies": [ + "@firebase/util", + "@parcel/watcher", + "esbuild", + "protobufjs", + "unrs-resolver" + ] + }, "dependencies": { "@mdi/js": "^7.4.47", "@nuxtjs/google-fonts": "^3.2.0", From 48e59e47b53a8b00b9e8ec025fbb62d479b9b666 Mon Sep 17 00:00:00 2001 From: Daxsum Date: Sat, 12 Sep 2026 23:31:52 +0200 Subject: [PATCH 3/7] fix(web): copy pnpm-workspace.yaml before pnpm install in Docker build pnpm's ignored-builds gate (ERR_PNPM_IGNORED_BUILDS) needs the allowBuilds list from pnpm-workspace.yaml, but the Dockerfile only copied package.json/pnpm-lock.yaml before running `pnpm install`, so the allowlist wasn't visible yet and the build failed. Revert the unrelated package.json "pnpm" field added in 6a1775a0 since it wasn't the actual fix. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01L5D2TYHpgXXXCrUJpcQYxg --- web/Dockerfile | 2 +- web/package.json | 9 --------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/web/Dockerfile b/web/Dockerfile index 545803cb6..5bb4455bf 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -3,7 +3,7 @@ FROM node:22-alpine AS build WORKDIR /app -COPY package.json pnpm-lock.yaml ./ +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ # Install pnpm RUN npm install -g pnpm diff --git a/web/package.json b/web/package.json index fc17ce137..bfec579c7 100644 --- a/web/package.json +++ b/web/package.json @@ -22,15 +22,6 @@ "*.{css,scss,sass,vue}": "stylelint", "*": "prettier --check --ignore-unknown" }, - "pnpm": { - "onlyBuiltDependencies": [ - "@firebase/util", - "@parcel/watcher", - "esbuild", - "protobufjs", - "unrs-resolver" - ] - }, "dependencies": { "@mdi/js": "^7.4.47", "@nuxtjs/google-fonts": "^3.2.0", From b7fb0869b26d8bf2e60dfec52c4ab7ca88ead966 Mon Sep 17 00:00:00 2001 From: Daxsum Date: Sat, 12 Sep 2026 23:42:30 +0200 Subject: [PATCH 4/7] fix: pin postgres image to 16-alpine The floating "alpine" tag now resolves to Postgres 18, whose image refuses to start when the data volume is mounted directly at /var/lib/postgresql/data (its new expected layout mounts the parent /var/lib/postgresql instead) -- it errors out even against an empty volume. Pin to a stable major version compatible with the classic mount path used here. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01L5D2TYHpgXXXCrUJpcQYxg --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index bd61721a6..25fa862f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: postgres: - image: postgres:alpine + image: postgres:16-alpine environment: POSTGRES_DB: httpsms POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} From 083d6c1831dba6ebe46ca23e5e7f6b45a1a0f85c Mon Sep 17 00:00:00 2001 From: Daxsum Date: Sat, 12 Sep 2026 23:48:13 +0200 Subject: [PATCH 5/7] fix(api): use ENV=local instead of production The api container was crash-looping: logDriver() only uses the plain console logger when ENV=local, otherwise it unconditionally requires an Axiom token and log.Fatal()s without one. This deployment has no Axiom account configured, matching api/.env.docker's own default of ENV=local for self-hosted setups. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01L5D2TYHpgXXXCrUJpcQYxg --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 25fa862f6..5ee0e3309 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,7 +33,7 @@ services: restart: on-failure environment: SERVICE_FQDN_API_8000: - ENV: production + ENV: local GCP_PROJECT_ID: ${GCP_PROJECT_ID} USE_HTTP_LOGGER: "true" ENTITLEMENT_ENABLED: "false" From 0ef0761d05c7dc514eb1a2e6845bb52e3ce1bac5 Mon Sep 17 00:00:00 2001 From: Daxsum Date: Sun, 13 Sep 2026 10:13:31 +0200 Subject: [PATCH 6/7] fix: use unsuffixed SERVICE_URL/FQDN vars for APP_URL/API_BASE_URL/SWAGGER_HOST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coolify only writes the port-less SERVICE_URL_ and SERVICE_FQDN_ into the generated .env used for docker-compose variable interpolation; the port-suffixed names (SERVICE_URL_WEB_3000, SERVICE_URL_API_8000, SERVICE_FQDN_API_8000) only exist as Coolify-injected literals inside each container's own runtime env block, not as compose-level interpolation sources. Referencing the suffixed names in `environment:`/`args:` values resolved to empty strings, so APP_URL and API_BASE_URL were blank — and since the web app is built statically (nuxt generate, ssr: false), that blank got baked into the build as the `http://localhost:8000` fallback, breaking the deployed site for every visitor. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Q6NkJzg4XWGQPE5AEeJ6U6 --- docker-compose.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 5ee0e3309..aff543bc4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,10 +49,10 @@ services: SMTP_PASSWORD: ${SMTP_PASSWORD} SMTP_HOST: ${SMTP_HOST} SMTP_PORT: ${SMTP_PORT} - APP_URL: ${SERVICE_URL_WEB_3000} + APP_URL: ${SERVICE_URL_WEB} APP_NAME: httpSMS APP_PORT: 8000 - SWAGGER_HOST: ${SERVICE_FQDN_API_8000} + SWAGGER_HOST: ${SERVICE_FQDN_API} DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/httpsms DATABASE_URL_DEDICATED: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/httpsms REDIS_URL: redis://@redis:6379 @@ -70,8 +70,8 @@ services: build: context: ./web args: - API_BASE_URL: ${SERVICE_URL_API_8000} - APP_URL: ${SERVICE_URL_WEB_3000} + API_BASE_URL: ${SERVICE_URL_API} + APP_URL: ${SERVICE_URL_WEB} APP_NAME: httpSMS APP_GITHUB_URL: https://github.com/NdoleStudio/httpsms APP_DOCUMENTATION_URL: https://docs.httpsms.com From 0d7985b8fb4bbde97f2010cef6553fddacdb617c Mon Sep 17 00:00:00 2001 From: Daxsum Date: Mon, 14 Sep 2026 16:56:30 +0200 Subject: [PATCH 7/7] fix(api): gate message-quota paywall behind BILLING_ENABLED, off by default Self-hosted deployments had no way to disable the per-plan message send/receive quota (200/month on the free plan) and the "Upgrade to send more messages" 402 response, unlike the existing ENTITLEMENT_ENABLED-gated contact/API-key/schedule limits. Add the same enabled-flag pattern to BillingService so self-hosters aren't capped by default. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0147RMBR3grGHNtHNj5PypNU --- api/.env.docker | 5 +++++ api/pkg/di/container.go | 1 + api/pkg/services/billing_service.go | 8 ++++++++ 3 files changed, 14 insertions(+) diff --git a/api/.env.docker b/api/.env.docker index 235972f4d..96783afef 100644 --- a/api/.env.docker +++ b/api/.env.docker @@ -9,6 +9,11 @@ USE_HTTP_LOGGER=true # Defaults to "false" for self-hosted deployments (no limits). ENTITLEMENT_ENABLED=false +# Set to "true" to enable the per-plan message send/receive quota (e.g. 200 +# messages/month on the free plan) and the "Upgrade to send more messages" +# paywall. Defaults to "false" for self-hosted deployments (no limits). +BILLING_ENABLED=false + EVENTS_QUEUE_TYPE=emulator EVENTS_QUEUE_NAME=events-local EVENTS_QUEUE_ENDPOINT=http://localhost:8000/v1/events diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index 8d9e778d4..6b2920de1 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -999,6 +999,7 @@ func (container *Container) BillingService() (service *services.BillingService) return services.NewBillingService( container.Logger(), container.Tracer(), + os.Getenv("BILLING_ENABLED") == "true", container.InMemoryCache(), container.Mailer(), container.UserEmailFactory(), diff --git a/api/pkg/services/billing_service.go b/api/pkg/services/billing_service.go index e94002d1d..41f05e036 100644 --- a/api/pkg/services/billing_service.go +++ b/api/pkg/services/billing_service.go @@ -20,6 +20,7 @@ type BillingService struct { service logger telemetry.Logger tracer telemetry.Tracer + enabled bool cache cache.Cache emailFactory emails.UserEmailFactory mailer emails.Mailer @@ -28,9 +29,11 @@ type BillingService struct { } // NewBillingService creates a new BillingService +// The enabled flag should come from the BILLING_ENABLED environment variable. func NewBillingService( logger telemetry.Logger, tracer telemetry.Tracer, + enabled bool, cache cache.Cache, mailer emails.Mailer, emailFactory emails.UserEmailFactory, @@ -40,6 +43,7 @@ func NewBillingService( return &BillingService{ logger: logger.WithService(fmt.Sprintf("%T", s)), tracer: tracer, + enabled: enabled, cache: cache, emailFactory: emailFactory, mailer: mailer, @@ -53,6 +57,10 @@ func (service *BillingService) IsEntitledWithCount(ctx context.Context, userID e ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() + if !service.enabled { + return nil + } + user, err := service.userRepository.Load(ctx, userID) if err != nil { ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load user with ID [%s], entitlement successful", userID)))