# The whole platform in one file (D143): the engine with its admin panel, # PostgreSQL, Meilisearch, the shopper storefront (Svelte Commerce) and Caddy # in front of them with automatic HTTPS. # # docker compose up -d # # Every variable is listed, secrets as placeholders, in .env.example; copy it # to .env and fill it in, or set the same names in the host's environment tab. # # Caddy is in the "proxy" profile, which .env.example switches on # (COMPOSE_PROFILES=proxy), so the command above starts it once .env is copied # from there. On Dokploy this is a Compose service whose environment is its # own tab: leave COMPOSE_PROFILES out, since Dokploy's Traefik holds ports 80 # and 443, and no Caddy container is made. Dokploy's domain entries route to # gocommerce:8080 (the panel) and storefront:3000 (the shops) by service name. Set the variables in the # panel's Environment tab rather than committing them — POSTGRES_PASSWORD and # GOCOMMERCE_ADMIN_TOKEN are secrets, and the admin password is used once. services: gocommerce: build: context: . restart: unless-stopped # The binary installs no module unless told to, and the panel hides every # screen whose module is missing, so a store started without these has no # B2B, Invoices, Payouts or Abandoned carts and gives no hint why. Each # module sits idle until it is configured, so installing them all costs # nothing. -import-amazon is left out: it drives a real Chrome. command: - -addr - 0.0.0.0:8080 - -identity - -webhooks - -menus - -reviews - -contact - -newsletter - -resend - -sendgrid - -twilio - -msg91 - -telegram - -whatsapp - -invoices - -cms - -faq - -translations - -showcases - -metal-pricing - -retail-assistant - -gokwik - -banners - -layouts - -wishlist - -klaviyo - -mcp - -customizer - -gateways - -carriers - -feeds - -google-reviews - -meta-capi - -sitemaps - -b2b - -recovery # Search through the meilisearch service below. Each store switches it # on from Plugins; until then, and whenever Meilisearch is down, search # answers from the database. - -meilisearch # The AI gateway: with ANTHROPIC_API_KEY below, every store's AI runs on # the platform's key and is paid from its AI credit; without it, nothing # is called until a store sets a key, a cap and prices under Plugins. # -photo-products runs through it: "Create from photos" on the products # page. So do the Reviews screen's summaries, which -reviews above brings. - -ai-gateway - -photo-products - -catalog-ai # "Ask AI" in the panel: setup questions, and changes to the store's # details that a person applies. Runs through the gateway above. - -ask-ai # The shop assistant on the storefront. Off for every store until the store # switches it on under Plugins; shoppers then spend that store's AI credit. - -shop-assistant # Ads intelligence: the ads report, ad spend and its AI summary. - -ads - serve depends_on: postgres: condition: service_healthy # The engine's login must exist before the engine signs in with it. engine-login: condition: service_completed_successfully environment: # gocommerce_app, not the superuser: every store's rows share one set of # tables behind row-level-security policies, and a superuser passes # every policy (D83). The engine-login service below creates it, on a # fresh volume and an existing one alike. DATABASE_URL: postgres://gocommerce_app:${ENGINE_DB_PASSWORD:-${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}}@postgres:5432/gocommerce?sslmode=disable # Custom reports run arbitrary operator SQL, so they run as a separate, # read-only role — never the engine's own, which owns every table and could # terminate connections or read files off the server (D78). Leave this # unset and custom reports are simply disabled; saving and listing still # work. To turn them on, set REPORTS_DB_PASSWORD below (the postgres # service creates the gocommerce_reports role from it on a fresh volume) # and set this URL to match. For an existing database, run # scripts/reports-role.sql once instead. GOCOMMERCE_REPORTS_DATABASE_URL: ${GOCOMMERCE_REPORTS_DATABASE_URL:-} # The credential scripts use. A person signs in with the email and # password below instead; the engine accepts either on the same routes. GOCOMMERCE_ADMIN_TOKEN: ${GOCOMMERCE_ADMIN_TOKEN:?set GOCOMMERCE_ADMIN_TOKEN} # Creates the first operator, and only while there is none — so leaving # these set does not reset a password on every restart. GOCOMMERCE_ADMIN_EMAIL: ${GOCOMMERCE_ADMIN_EMAIL:-admin@example.com} GOCOMMERCE_ADMIN_PASSWORD: ${GOCOMMERCE_ADMIN_PASSWORD:?set GOCOMMERCE_ADMIN_PASSWORD} # Seals the secrets the engine reads back — payment and provider keys, # the copy of an API key, webhook signing secrets — so the database # holds ciphertext. 32 random bytes in base64 (openssl rand -base64 32). # Unset, they are stored readable as before. After setting it, redeploy: # the engine seals what is already stored as it starts. Keep the key # somewhere other than this server, because losing it means entering # every secret again. GOCOMMERCE_SECRETS_KEY: ${GOCOMMERCE_SECRETS_KEY:-} # Only while rotating: the old key, which still opens what it sealed # until the engine, started with both, has moved everything to the new # one. GOCOMMERCE_SECRETS_KEY_PREVIOUS: ${GOCOMMERCE_SECRETS_KEY_PREVIOUS:-} # Where the panel is reached, e.g. https://shop.example.com: the base of # a password-reset link. Unset, a reset email carries only a code. GOCOMMERCE_PANEL_URL: ${GOCOMMERCE_PANEL_URL:-} # "Continue with Google" on the panel. Unset, the button is not shown. GOCOMMERCE_GOOGLE_CLIENT_ID: ${GOCOMMERCE_GOOGLE_CLIENT_ID:-} GOCOMMERCE_GOOGLE_CLIENT_SECRET: ${GOCOMMERCE_GOOGLE_CLIENT_SECRET:-} # The hosted funnel (sign_up, store_created, first_product_added) sent # to Google Analytics by the server. Unset, nothing is sent. GA_MEASUREMENT_ID: ${GA_MEASUREMENT_ID:-} GA_API_SECRET: ${GA_API_SECRET:-} # Where emails send a store's buyers and shoppers. {domain} is each # store's own address, so one setting serves every store (D236). The # storefront forwards /portal to this engine and has its own account # screens; a store with neither address nor domain gets a code instead. GOCOMMERCE_B2B_INVITE_URL: ${GOCOMMERCE_B2B_INVITE_URL:-https://{domain}/portal/invitation?token={token}} GOCOMMERCE_IDENTITY_RESET_URL: ${GOCOMMERCE_IDENTITY_RESET_URL:-https://{domain}/auth/reset-password?token={token}} GOCOMMERCE_IDENTITY_VERIFY_URL: ${GOCOMMERCE_IDENTITY_VERIFY_URL:-https://{domain}/auth/verify?token={token}} # Where the storefront below forwards to the engine (svelte-commerce's # /backend). Cart reminders link through it, so a shopper sees the # shop's domain, not the panel's. Empty links to the panel. STOREFRONT_ENGINE_PATH: ${STOREFRONT_ENGINE_PATH:-/backend} # 1 lets anybody make an account on the sign-in screen and open a store. # It stays shut until the install has email of its own (RESEND_API_KEY # or SENDGRID_API_KEY below), so whoever signs up can reset a password. GOCOMMERCE_SIGNUP: ${GOCOMMERCE_SIGNUP:-} # The sign-in codes texted in 24 hours, to operators in total and to each # store's shoppers in total. Unset, 500. GOCOMMERCE_SMS_CODES_PER_DAY: ${GOCOMMERCE_SMS_CODES_PER_DAY:-} # The calling codes an operator's sign-in code may be texted to, such as # 91,1. Unset or unreadable, 91. GOCOMMERCE_SMS_COUNTRIES: ${GOCOMMERCE_SMS_COUNTRIES:-} # The install owner's own bot and chat, told whenever anybody opens a # store. Not a store's: each store sets its own for its orders. # The retail assistant's one WhatsApp number, shared by every store that # switches it on (docs/retail-assistant.md). Unset, nothing can arrive. RETAIL_ASSISTANT_APP_SECRET: ${RETAIL_ASSISTANT_APP_SECRET:-} RETAIL_ASSISTANT_VERIFY_TOKEN: ${RETAIL_ASSISTANT_VERIFY_TOKEN:-} RETAIL_ASSISTANT_ACCESS_TOKEN: ${RETAIL_ASSISTANT_ACCESS_TOKEN:-} RETAIL_ASSISTANT_PHONE_NUMBER_ID: ${RETAIL_ASSISTANT_PHONE_NUMBER_ID:-} RETAIL_ASSISTANT_DISPLAY_NUMBER: ${RETAIL_ASSISTANT_DISPLAY_NUMBER:-} RETAIL_ASSISTANT_QUIET_TO_STRANGERS: ${RETAIL_ASSISTANT_QUIET_TO_STRANGERS:-} RETAIL_ASSISTANT_FILE_KEY: ${RETAIL_ASSISTANT_FILE_KEY:-} RETAIL_ASSISTANT_CLAMD_ADDR: ${RETAIL_ASSISTANT_CLAMD_ADDR:-} RETAIL_ASSISTANT_TEMPLATE: ${RETAIL_ASSISTANT_TEMPLATE:-} RETAIL_ASSISTANT_LOGIN_TEMPLATE: ${RETAIL_ASSISTANT_LOGIN_TEMPLATE:-} PLATFORM_TELEGRAM_BOT_TOKEN: ${PLATFORM_TELEGRAM_BOT_TOKEN:-} PLATFORM_TELEGRAM_CHAT_ID: ${PLATFORM_TELEGRAM_CHAT_ID:-} PLATFORM_STORE_WEBHOOK_URL: ${PLATFORM_STORE_WEBHOOK_URL:-} PLATFORM_STORE_WEBHOOK_BASE_DOMAIN: ${PLATFORM_STORE_WEBHOOK_BASE_DOMAIN:-shopnx.in} GOCOMMERCE_PLATFORM_OWNER_PHONE: ${GOCOMMERCE_PLATFORM_OWNER_PHONE:-} GOCOMMERCE_PLATFORM_EMAIL_FROM: ${GOCOMMERCE_PLATFORM_EMAIL_FROM:-hi@shopnx.in} # GOCOMMERCE_ALLOW_PRIVATE_URLS is deliberately not passed through. It # lets the addresses a store sets — webhook endpoints, provider base # URLs — reach this server's own network, which is wrong wherever people # who do not run the server can open a store. A single business's own # install that wants it adds the line here. # The proxies whose X-Forwarded-For the engine believes, comma-separated # CIDR ranges or addresses: the storefront service and whatever proxy # reaches the engine, as the engine sees them. Empty, every shopper the # storefront proxies counts as the storefront, so one shopper at the # order-lookup or sign-in limit is the whole store at it # (docs/operations.md, "Behind a proxy"). The default is the private ranges # Docker networks use, where the storefront and proxy are. GOCOMMERCE_TRUSTED_PROXIES: ${GOCOMMERCE_TRUSTED_PROXIES:-10.0.0.0/8,172.16.0.0/12,192.168.0.0/16} # Where every store has an address without adding a domain: # ., e.g. shops.example.com. The panel shows it, and # a domain under it cannot be added as a store's own. GOCOMMERCE_BASE_DOMAIN: ${GOCOMMERCE_BASE_DOMAIN:-} # Where a store's own domain should point — this server's IP address, or # a host name to CNAME to. Settings › Domains shows it as the record to # add, beside the TXT record that proves the domain. GOCOMMERCE_DOMAIN_TARGET: ${GOCOMMERCE_DOMAIN_TARGET:-} # Routes every verified store domain without adding it to the proxy by # hand (D113). Set a long random token, then add Traefik's HTTP provider # pointing at http://gocommerce:8080/internal/traefik with the # header "Authorization: Bearer " (docs/operations.md). The # upstream is where a store domain's visitors go. GOCOMMERCE_TRAEFIK_TOKEN: ${GOCOMMERCE_TRAEFIK_TOKEN:-} GOCOMMERCE_TRAEFIK_UPSTREAM: ${GOCOMMERCE_TRAEFIK_UPSTREAM:-} GOCOMMERCE_TRAEFIK_CERT_RESOLVER: ${GOCOMMERCE_TRAEFIK_CERT_RESOLVER:-} # Several brands on one install, each a name on its own admin domain # with its own stores (docs/operations.md, "Several brands"): a JSON # array of {"name", "panel_url", "base_domain", "alias_base_domains", # "reserved", "logo_url"}. Unset, the install is one brand made of the # two settings above. GOCOMMERCE_BRANDS: ${GOCOMMERCE_BRANDS:-} GOCOMMERCE_BRAND_NAME: ${GOCOMMERCE_BRAND_NAME:-ShopNx} # A checkout fills the city and state from a PIN code, from India # Post's directory built into the engine. Set this only to mount a newer # copy of the CSV data.gov.in publishes, or to "off". GOCOMMERCE_POSTCODES_FILE: ${GOCOMMERCE_POSTCODES_FILE:-} # How many photos and designs one address may send the customizer in an # hour, 30 each when unset. Raise them only where many shoppers share # one address, such as a mobile carrier's NAT. GOCOMMERCE_CUSTOMIZER_UPLOADS_PER_HOUR: ${GOCOMMERCE_CUSTOMIZER_UPLOADS_PER_HOUR:-} GOCOMMERCE_CUSTOMIZER_DESIGNS_PER_HOUR: ${GOCOMMERCE_CUSTOMIZER_DESIGNS_PER_HOUR:-} # Email and SMS delivery, every one optional: a backend without its key # installs idle and waits for Notifications › Setup Email / Setup SMS. # The engine sees only what is listed here, so a key set in the host's # environment tab and missing from this list never reaches it. # DEFAULT_EMAIL_FROM_ADDRESS is the sender for Resend and SendGrid when # their own *_FROM is empty; it must be on a domain the provider has # verified. Keys entered on a store's own screens win over these. DEFAULT_EMAIL_FROM_ADDRESS: ${DEFAULT_EMAIL_FROM_ADDRESS:-} RESEND_API_KEY: ${RESEND_API_KEY:-} RESEND_FROM: ${RESEND_FROM:-} RESEND_FROM_NAME: ${RESEND_FROM_NAME:-} SENDGRID_API_KEY: ${SENDGRID_API_KEY:-} SENDGRID_FROM: ${SENDGRID_FROM:-} SENDGRID_FROM_NAME: ${SENDGRID_FROM_NAME:-} TWILIO_ACCOUNT_SID: ${TWILIO_ACCOUNT_SID:-} TWILIO_AUTH_TOKEN: ${TWILIO_AUTH_TOKEN:-} TWILIO_FROM: ${TWILIO_FROM:-} TWILIO_MESSAGING_SERVICE_SID: ${TWILIO_MESSAGING_SERVICE_SID:-} # MSG91's dashboard calls it the authkey, so either name works. MSG91_AUTH_KEY: ${MSG91_AUTH_KEY:-${MSG91_AUTHKEY:-}} # The install's DLT template ids, every store's default where it has set # none of its own (msg91.EnvTemplates). MSG91_TEMPLATE_ORDER_PLACED: ${MSG91_TEMPLATE_ORDER_PLACED:-} MSG91_TEMPLATE_PAYMENT_RECEIVED: ${MSG91_TEMPLATE_PAYMENT_RECEIVED:-} MSG91_TEMPLATE_ORDER_SHIPPED: ${MSG91_TEMPLATE_ORDER_SHIPPED:-} MSG91_TEMPLATE_ORDER_DELIVERED: ${MSG91_TEMPLATE_ORDER_DELIVERED:-} MSG91_TEMPLATE_ORDER_CANCELLED: ${MSG91_TEMPLATE_ORDER_CANCELLED:-} MSG91_TEMPLATE_REFUND_ISSUED: ${MSG91_TEMPLATE_REFUND_ISSUED:-} MSG91_TEMPLATE_ORDER_CODE: ${MSG91_TEMPLATE_ORDER_CODE:-} SMS_OTP_TEMPLATE_ID: ${SMS_OTP_TEMPLATE_ID:-} # The DLT sender id every text goes from, such as MISIKI. SMS_SENDER_ID: ${SMS_SENDER_ID:-} # The install's Meilisearch, which every store that switches the plugin # on uses without typing a host or key. The master key writes the # index; a search-only key is optional (the engine searches with the # master key when there is none) and never leaves the server either way. # The host is left empty until the key is set, so a store cannot point # a key of its own at the install's Meilisearch in the meantime. MEILI_HOST: ${MEILI_MASTER_KEY:+http://meilisearch:7700} MEILI_API_KEY: ${MEILI_MASTER_KEY:-} MEILI_SEARCH_KEY: ${MEILI_SEARCH_KEY:-} # The platform's own Anthropic key. Every store's AI runs on it — Create # from photos, Improve with AI, review summaries — with nothing to set # up, and each call is paid from that store's AI credit. A store that # enters a key of its own under Plugins › AI gateway uses that instead # and draws no credit. Empty: AI works only for stores with their own key. ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} # What platform calls use and cost. Empty is Claude Haiku 5.5 and # Claude Sonnet 5.5 at Anthropic's published prices. Prices are US # dollars per million tokens, "input,output,cache read,cache write"; a # model named here needs its prices too, or it is not called. AI_PLATFORM_SMALL_MODEL: ${AI_PLATFORM_SMALL_MODEL:-} AI_PLATFORM_SMALL_PRICES: ${AI_PLATFORM_SMALL_PRICES:-} AI_PLATFORM_STRONG_MODEL: ${AI_PLATFORM_STRONG_MODEL:-} AI_PLATFORM_STRONG_PRICES: ${AI_PLATFORM_STRONG_PRICES:-} # Anthropic bills in dollars; a store's credit is in its own currency. # Units of each currency to one US dollar, e.g. INR=88.50,EUR=0.92. A # store in a currency with no rate here cannot use the platform's key. AI_PLATFORM_USD_RATES: ${AI_PLATFORM_USD_RATES:-} # A new store's free AI trial, whole minor units per currency, e.g. # INR=3000,USD=50. Empty is INR=20000 (₹200); a currency not named gets # the first amount's worth at the rates above. AI_FREE_TRIAL: ${AI_FREE_TRIAL:-} volumes: - media:/data/media ports: - "${PORT:-8080}:8080" # The shopper storefront: Svelte Commerce, built from its git repository at # STOREFRONT_REF (the branch wired to GoCommerce), since no image of it is # published. One container serves every store: . and # every verified domain of a store. storefront: build: context: https://github.com/itswadesh/svelte-commerce.git#${STOREFRONT_REF:-gocomm} restart: unless-stopped depends_on: - gocommerce environment: PORT: "3000" # The shopper's address, from the proxy that reaches this service (Caddy # or Traefik, each of which writes it last). The storefront forwards it # to the engine, which counts sign-ins and enquiries against it once # GOCOMMERCE_TRUSTED_PROXIES lists this service. Behind Cloudflare it reads # Cloudflare's own header instead; behind another CDN, raise the depth by one. ADDRESS_HEADER: X-Forwarded-For XFF_DEPTH: ${STOREFRONT_XFF_DEPTH:-1} # The engine on this compose network, for server-side rendering and for # the shopper's API calls, which reach it through the storefront's own # /backend path because the engine answers no CORS. Not the public # address: the storefront's server never has to leave this host. PUBLIC_GOCOMMERCE_API_URL: ${STOREFRONT_ENGINE_URL:-http://gocommerce:8080} PUBLIC_GOCOMMERCE_PROXY_PATH: /backend # A request for acme. sells for store acme. PUBLIC_GOCOMMERCE_BASE_DOMAIN: ${GOCOMMERCE_BASE_DOMAIN:-} # Optional: the store every other host sells for, on a one-shop install. PUBLIC_GOCOMMERCE_STORE: ${STOREFRONT_STORE:-} # No published port: the proxy reaches it at storefront:3000. # HTTPS in front of both. The panel answers at ADMIN_DOMAIN; every other # name goes to the storefront, with a certificate fetched the first time a # browser asks for it, and only for a name the engine says is a store's # (GET /internal/caddy/ask). See scripts/Caddyfile. # # Opt-in (D176): started only with the "proxy" profile on. Off by default, # because on a host whose own proxy holds ports 80 and 443 (Dokploy) a # Caddy container fails the whole deploy on the port, and a forgotten # switch there must not. caddy: image: caddy:2.10.2-alpine restart: unless-stopped profiles: [proxy] depends_on: - gocommerce - storefront environment: ADMIN_DOMAIN: ${ADMIN_DOMAIN:-admin.localhost} # Extra Caddy global options, such as `local_certs` for a trial on # *.localhost with Caddy's own certificate authority. Empty in # production, which gets certificates from Let's Encrypt. CADDY_GLOBAL_OPTIONS: ${CADDY_GLOBAL_OPTIONS:-} volumes: - ./scripts/Caddyfile:/etc/caddy/Caddyfile:ro # The certificates and the account key: lose them and every name is # issued again, which Let's Encrypt rate-limits. - caddydata:/data - caddyconfig:/config ports: - "${HTTP_PORT:-80}:80" - "${HTTPS_PORT:-443}:443" # Makes sure the engine's own login exists and owns the tables, on every # start. Idempotent, so an existing volume — one made before the engine # stopped signing in as the superuser — is brought up to date without # anyone running a script by hand. engine-login: image: postgres:17-alpine restart: "no" depends_on: postgres: condition: service_healthy environment: PGPASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} volumes: - ./scripts/engine-login.sql:/opt/gocommerce/engine-login.sql:ro command: - psql - --host=postgres - --username=gocommerce - --dbname=gocommerce - --set=ON_ERROR_STOP=1 - --set=engine_password=${ENGINE_DB_PASSWORD:-${POSTGRES_PASSWORD}} - --file=/opt/gocommerce/engine-login.sql postgres: image: postgres:17-alpine restart: unless-stopped environment: POSTGRES_USER: gocommerce POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} POSTGRES_DB: gocommerce # Used only on a fresh volume, by the init script below, to create the # read-only custom-reports role (D78). Unset means no role and reports # stay disabled. It must match the password in GOCOMMERCE_REPORTS_DATABASE_URL. REPORTS_DB_PASSWORD: ${REPORTS_DB_PASSWORD:-} # The engine's login (gocommerce_app). Defaults to POSTGRES_PASSWORD. ENGINE_DB_PASSWORD: ${ENGINE_DB_PASSWORD:-} volumes: - pgdata:/var/lib/postgresql/data # Runs once, on first init of the volume, to create gocommerce_reports. - ./scripts/reports-role.sql:/opt/gocommerce/reports-role.sql:ro - ./scripts/initdb-reports-role.sh:/docker-entrypoint-initdb.d/10-reports-role.sh:ro ports: - "5450:5432" healthcheck: # The engine applies its migrations on boot, so it must not start against # a database that is still initialising. test: ["CMD-SHELL", "pg_isready -U gocommerce -d gocommerce"] interval: 5s timeout: 5s retries: 20 # The search index, reachable only on this compose network: no port is # published, and the engine is the only thing that talks to it. # # In production Meilisearch will not start without MEILI_MASTER_KEY of at # least 16 bytes (openssl rand -base64 32). That is deliberate here: the # engine does not wait for this service, so a deploy without the key still # brings the store up, with this container restarting and every store's # search answering from the database. Set the key and redeploy to turn it on. # # Memory: about the size of the index on disk plus some headroom; a few # thousand products across all stores fits in 256 MB. Indexing is the peak. meilisearch: image: getmeili/meilisearch:v1.55.0 restart: unless-stopped environment: MEILI_ENV: production MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-} MEILI_NO_ANALYTICS: "true" volumes: - meilidata:/meili_data healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:7700/health"] interval: 10s timeout: 5s retries: 10 volumes: media: pgdata: meilidata: caddydata: caddyconfig: