Skip to content

Docker deployment

Marketix ships a production-ready docker-compose.yml that runs all required services in a single command. This page explains each service, the network topology, and how to wire custom short-link domains into the Traefik edge router.

  • Docker Engine 24+ and the Compose v2 plugin (docker compose)
  • A domain pointed at your server’s public IP (for APP_DOMAIN and TLS)
  • An .env file on the host with the variables described in Environment configuration

The Compose file defines seven services across two Docker networks.

Image: traefik:v3.3

The edge router. It handles:

  • HTTP→HTTPS redirect — all port 80 traffic is permanently redirected to port 443.
  • TLS termination — certificates are issued automatically via Let’s Encrypt using the TLS-ALPN-01 challenge. The ACME email address comes from LETSENCRYPT_EMAIL.
  • Docker provider — Traefik watches the Docker socket and picks up Marketix’s routing labels from the app container.
  • File provider — Traefik watches the traefik_dynamic volume for a YAML file (custom-domains.yml) written by the app when project custom domains are added or changed. This is how short-link custom domains get their TLS certificates without a Traefik restart.

Traefik mounts:

  • /var/run/docker.sock (read-only) for the Docker provider
  • traefik_acme volume at /acme to persist the ACME account and issued certificates
  • traefik_dynamic volume at /traefik (read-only) for the file provider

Image: noixdev/marketix:latest

The main web application. Runs Laravel Octane on FrankenPHP on port 8000. The entrypoint (docker/entrypoint.sh) waits for the database to become reachable, then — when RUN_MIGRATIONS=true — caches config and views and runs php artisan migrate --force before starting Octane.

The app container:

  • Mounts the storage volume at /app/storage to persist uploaded files, generated PDFs, QR code assets, and logs.
  • Mounts the traefik_dynamic volume at /traefik (read-write) so the RegenerateTraefikConfigJob job can write custom-domains.yml whenever project domains change.
  • Exposes itself to Traefik via Docker labels:
labels:
- traefik.enable=true
- traefik.docker.network=web
- traefik.http.routers.marketix.rule=Host(`${APP_DOMAIN}`)
- traefik.http.routers.marketix.entrypoints=websecure
- traefik.http.routers.marketix.tls.certresolver=letsencrypt
- traefik.http.services.marketix.loadbalancer.server.port=8000

APP_DOMAIN is your primary application hostname (e.g. app.example.com). The admin panel and the project dashboard are all served from this domain.

Image: noixdev/marketix:latest

Runs php artisan horizon to process queued jobs. Horizon uses Redis as its queue backend (QUEUE_CONNECTION=redis).

Jobs that run through the queue include:

  • Regenerating the Traefik custom-domain config file when domains are added, edited, or removed.
  • Any other background work dispatched by the application.

Horizon waits for the app container to become healthy (which means migrations have already run) before starting.

Image: noixdev/marketix:latest

Runs php artisan schedule:work, keeping the Laravel scheduler alive. This handles any periodic tasks registered in the application (GeoIP database expiry checks, cleanup jobs, and similar cron-based work).

Image: mariadb:11.8

MariaDB database server. Data is persisted in the dbdata volume. The root password is set from MYSQL_ROOT_PASSWORD. The application user, password, and database are all set to db inside the Compose file.

The service exposes a healthcheck (healthcheck.sh --connect --innodb_initialized) so that the app and horizon containers wait for the database to be fully ready before starting.

Image: redis:7-alpine

Redis server with append-only persistence and password authentication. The horizon and app containers connect using the REDIS_PASSWORD from your .env.

Image: ghcr.io/nicholas-fedor/watchtower:latest

Automatically pulls and redeploys updated images every five minutes. Only containers labelled com.centurylinklabs.watchtower.enable=true are watched — these are app, horizon, and scheduler. The stateful traefik, db, and redis containers are deliberately excluded.

NetworkPurpose
webShared between Traefik and the app container. Traefik routes inbound traffic to app over this network.
marketix_internalBackend network for app, horizon, scheduler, db, and redis. Not exposed to the host.
VolumeContents
storageLaravel storage directory: uploads, logs, generated PDFs, QR code assets
dbdataMariaDB data directory
redisdataRedis append-only log
traefik_acmeACME account and Let’s Encrypt certificates
traefik_dynamicDynamically generated Traefik file-provider config (custom-domains.yml)

When a project member adds a custom domain in the Marketix UI, the app dispatches a job (picked up by Horizon) that regenerates custom-domains.yml and writes it to the traefik_dynamic volume. Because Traefik’s file provider has watch=true, it reloads the config live — no restart required.

The generated file follows the same structure as the included custom-domains.yml example:

http:
routers:
custom-<slug>:
rule: Host(`short.example.com`)
entrypoints:
- websecure
service: laravel-app
tls:
certResolver: letsencrypt
services:
laravel-app:
loadBalancer:
servers:
- url: 'http://app:8000'

Before adding a custom domain in the UI, the app verifies reachability by fetching /.well-known/marketix on that domain. This endpoint returns {"app":"marketix"} and confirms that the domain’s DNS is pointed at your server and that the Traefik routing is resolving to the correct application instance.

  1. Copy .env.example to .env and fill in the required values (see Environment configuration).
  2. Pull the latest images:
Terminal window
docker compose pull
  1. Start the stack:
Terminal window
docker compose up -d
  1. Follow the startup logs to confirm the app container completes its health check:
Terminal window
docker compose logs -f app

You should see the Octane server start after migrations complete. Once the app container is healthy, horizon and scheduler will start automatically.

Continue to First run to create the first admin user and set up the GeoIP database.