Setting Up a Multi-Domain Website (Different Domains by Country)
When business operates in multiple countries, each typically gets its own domain: company.ru, company.kz, company.by, company.ua. One code engine serves all domains — different languages, prices, legal texts, phones. More complex than subdirectories but maximum local SEO signal and complete content isolation.
Domain Configuration
Central domains table links host to settings:
CREATE TABLE site_domains (
id SERIAL PRIMARY KEY,
host VARCHAR(253) UNIQUE NOT NULL, -- 'company.ru'
country_code CHAR(2) NOT NULL, -- 'RU', 'KZ', 'BY'
locale VARCHAR(10) NOT NULL, -- 'ru', 'kk', 'be'
currency CHAR(3) NOT NULL, -- 'RUB', 'KZT', 'BYN'
timezone VARCHAR(64) NOT NULL,
is_primary BOOLEAN DEFAULT false,
is_active BOOLEAN DEFAULT true,
meta JSONB DEFAULT '{}'
);
INSERT INTO site_domains VALUES
(DEFAULT, 'company.ru', 'RU', 'ru', 'RUB', 'Europe/Moscow', true, true, '{}'),
(DEFAULT, 'company.kz', 'KZ', 'kk', 'KZT', 'Asia/Almaty', false, true, '{}'),
(DEFAULT, 'company.by', 'BY', 'ru', 'BYN', 'Europe/Minsk', false, true, '{}');
Domain Detection Middleware
// app/Http/Middleware/ResolveSiteDomain.php
class ResolveSiteDomain
{
public function handle(Request $request, Closure $next): Response
{
$host = $request->getHost(); // 'company.kz'
$domain = SiteDomain::where('host', $host)
->where('is_active', true)
->first();
if (!$domain) {
// Unknown domain — redirect to primary
$primary = SiteDomain::where('is_primary', true)->firstOrFail();
return redirect("https://{$primary->host}" . $request->getRequestUri(), 301);
}
app()->instance('site.domain', $domain);
// Set locale and timezone
App::setLocale($domain->locale);
Carbon::setlocale($domain->locale);
date_default_timezone_set($domain->timezone);
return $next($request);
}
}
Multi-Tenant Laravel Configuration
// app/Providers/DomainServiceProvider.php
public function boot(): void
{
$this->app->resolving('current.domain', function () {
return app('site.domain');
});
// Override mail from for each domain
$this->app['events']->listen(MessageSending::class, function ($event) {
$domain = app('site.domain');
config([
'mail.from.address' => "noreply@{$domain->host}",
'mail.from.name' => config('app.name') . ' ' . strtoupper($domain->country_code),
]);
});
}
Content Storage by Domain
-- Translatable texts bound to domain
CREATE TABLE pages (
id SERIAL PRIMARY KEY,
slug VARCHAR(255) NOT NULL,
domain_id INTEGER REFERENCES site_domains(id),
-- NULL in domain_id = shared content for all domains
UNIQUE (slug, domain_id)
);
CREATE TABLE page_translations (
page_id INTEGER REFERENCES pages(id),
locale VARCHAR(10) NOT NULL,
title TEXT,
body TEXT,
meta_title TEXT,
meta_desc TEXT,
PRIMARY KEY (page_id, locale)
);
Fetch content with fallback to shared:
public function getPage(string $slug): Page
{
$domain = app('site.domain');
// Try domain-specific page first
$page = Page::where('slug', $slug)
->where('domain_id', $domain->id)
->first();
// Fallback to shared page
$page ??= Page::where('slug', $slug)
->whereNull('domain_id')
->firstOrFail();
return $page;
}
Nginx: Virtual Hosts for PHP-FPM
# /etc/nginx/sites-available/multisite.conf
server {
listen 443 ssl http2;
server_name company.ru company.kz company.by;
ssl_certificate /etc/letsencrypt/live/company.ru/fullchain.pem;
# Wildcard cert or multi-domain SAN
root /var/www/company/public;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param HTTP_HOST $host; # Pass real host to PHP
include fastcgi_params;
}
}
If domains on different servers — add load balancer with X-Forwarded-Host header, read via TrustProxies middleware.
SSL Certificates
Certbot with multi-domain SAN:
certbot certonly --nginx \
-d company.ru -d www.company.ru \
-d company.kz -d www.company.kz \
-d company.by -d www.company.by \
--email [email protected] \
--agree-tos
Alternative — wildcard *.company.ru + DNS challenge.
Crossdomain SEO
Each domain is separate site for Google. Link them with hreflang in <head>:
<!-- On company.ru -->
<link rel="alternate" hreflang="ru-RU" href="https://company.ru/products/item-1" />
<link rel="alternate" hreflang="ru-KZ" href="https://company.kz/products/item-1" />
<link rel="alternate" hreflang="ru-BY" href="https://company.by/products/item-1" />
Generate via helper:
function hreflangTags(string $path): string
{
$domains = SiteDomain::where('is_active', true)->get();
return $domains->map(fn($d) =>
"<link rel=\"alternate\" hreflang=\"{$d->locale}-{$d->country_code}\" href=\"https://{$d->host}{$path}\" />"
)->join("\n");
}
Cookie and Session Problem
Cookies don't transmit between different domains. Solutions:
SSO via shared token: click "Go to Kazakhstan site" generates one-time token, user redirected with it, second domain exchanges token for session.
Shared session storage: Redis with same SESSION_DOMAIN — but browser won't send cookie from .ru to .kz. Works only for subdomains.
Adding New Domain Timeline
Adding domain takes less than hour:
- Register domain, setup DNS A records — minutes to day (propagation)
- Add to
site_domains— 2 minutes - Add to SAN certificate (
certbot --expand) — 5 minutes - Add
server_nameto Nginx — 1 minute - Import or create content — depends on volume
Monitoring
Separate health check /health per domain. Uptime Robot or Checkly pings all domains minute, alert on unavailability. Check SSL expiry separately — via ssl_certificate_expire metric in Prometheus or third-party service.







