The website is deployed as a standalone Next.js Docker image to opensyria-prod.
Production is intentionally separate from the datasets API while reusing the same server and Cloudflare Tunnel.
Cloudflare Tunnel
opensyria.org -> http://website-proxy:80
api.opensyria.org -> http://proxy:80
opensyria-prod
/srv/opensyria/website
website-proxy -> active website slot
website-blue
website-green
/srv/opensyria/datasets-api
proxy -> active API slot
The website proxy has a distinct Docker service name, website-proxy, so it does not collide with the datasets API proxy service on the Cloudflare Tunnel network.
The nginx proxy uses a larger response-header buffer than the image default so
agent discovery Link headers can coexist with Next.js preload Link headers
without causing upstream sent too big header 502 responses.
Production deployment is handled by .github/workflows/deploy-production.yml.
The workflow runs on pushes to main and can also be started manually with workflow_dispatch.
Before deployment jobs run, the workflow resolves a deploy policy for the pushed
commit. Pushes whose commit message contains [skip ci] or [ci skip] skip the
deployment workflow. Pushes associated with pull requests opened by
dependabot[bot] also skip deployment, while normal CI can still run. Manual
workflow_dispatch runs remain deployable.
The build and deploy jobs both use the GitHub production environment.
This is required because NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID is a production
environment variable and must be available while building the Docker image.
The deployment flow:
- Install dependencies with the pinned pnpm version.
- Run
pnpm verify. - Build the Docker
runtimetarget forlinux/arm64. - Push
sha-<short-sha>andmainimage tags to GHCR. - Join the tailnet with
tailscale/github-action. - Copy
deploy/websiteto/srv/opensyria/website. - Write
/srv/opensyria/website/.env. - Pull the SHA-pinned image on the server.
- Start the inactive blue/green slot.
- Check
/health. - Reload
website-proxyto the new slot. - Stop the old slot after a short drain.
Dataset catalog and dataset detail pages use the application-level dataset cache and can be prerendered into the Docker image. After a dataset release, either deploy the website or wait for the configured cache lifetime when short catalog staleness is acceptable.
Use the GitHub production environment for deployment secrets and variables.
Required environment variables:
DEPLOY_HOST=<tailscale host or IP>
DEPLOY_USER=opensyria
NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID=<real Google tag ID, such as GT-WPDWW3NR>
NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID can be either a Google tag ID such as
GT-WPDWW3NR, G-..., or AW-..., or a Tag Manager container ID such as
GTM-ABC1234. Do not paste placeholder examples such as GTM-... or
GTM-XXXXXXX.
Required environment secrets:
TS_OAUTH_CLIENT_ID
TS_AUDIENCE
DEPLOY_SSH_PRIVATE_KEY
DEPLOY_SSH_KNOWN_HOSTS
Optional environment variables:
WEBSITE_PROXY_PORT=3100
WEBSITE_BLUE_HOST_PORT=3101
WEBSITE_GREEN_HOST_PORT=3102
The default ports are selected to avoid the datasets API production ports:
datasets-api proxy -> 3000
datasets-api blue -> 3001
datasets-api green -> 3002
website proxy -> 3100
website blue -> 3101
website green -> 3102
Do not set the optional port variables unless the server topology changes.
The website may reuse the existing OpenSyria GitHub Actions deploy key when the same server user and trust boundary are acceptable.
Copy the private key into DEPLOY_SSH_PRIVATE_KEY.
Copy the Tailscale host known-hosts entry into DEPLOY_SSH_KNOWN_HOSTS.
If a shared key is ever rotated or revoked, update every repository that uses it. A website-specific key can be created later if separate revocation is preferred.
The existing Cloudflare Tunnel should route hostnames by service name:
opensyria.org -> http://website-proxy:80
api.opensyria.org -> http://proxy:80
The deploy script connects the existing opensyria-datasets-api-cloudflared container to the website Docker network after website-proxy is created.
Use one canonical redirect rule for host and scheme:
(http.host in {"opensyria.org" "www.opensyria.org"} and (not ssl or http.host eq "www.opensyria.org"))
Redirect target:
concat("https://opensyria.org", http.request.uri.path)
Use status code 301 and preserve the query string. This avoids chains such as http://www.opensyria.org -> https://www.opensyria.org -> https://opensyria.org.
Validate the compose file with a temporary env file:
cp .env.example deploy/website/.env
docker compose -f deploy/website/compose.yaml config --quiet
rm deploy/website/.envBuild the runtime image:
docker build --target runtime -t opensyria/website .Run locally:
docker run --rm -p 3000:3000 --env-file .env.local opensyria/websiteManual deployment from the server:
cd /srv/opensyria/website
bin/deploy-blue-green.sh ghcr.io/open-syria/website:<tag>Check current services:
cd /srv/opensyria/website
docker compose ps
bin/check.shCheck logs:
cd /srv/opensyria/website
docker compose logs --tail=120 website-proxy
docker compose logs --tail=120 website-blue
docker compose logs --tail=120 website-greenThe website exposes:
GET /health
The blue/green script checks the inactive slot directly before switching the proxy.
The workflow also runs bin/check.sh after deployment. That script checks the local proxy health endpoint and a HEAD request to /.
The workflow writes /srv/opensyria/website/.env before deployment. Do not hand-edit it for normal production changes; update GitHub production environment variables instead.
Generated runtime values include:
NODE_ENV=production
NEXT_TELEMETRY_DISABLED=1
HOSTNAME=0.0.0.0
PORT=3000
NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID=<real production Google tag or Tag Manager ID>
WEBSITE_PROXY_PORT=3100
WEBSITE_BLUE_HOST_PORT=3101
WEBSITE_GREEN_HOST_PORT=3102
- Keep the Docker image standalone;
next.config.tsusesoutput: "standalone". - Keep production routing on the root domain, not
www. - Keep
api.opensyria.orgon the datasets API service. - Do not commit local
.envfiles, SSH keys, Cloudflare tokens, or Tailscale credentials. - Production analytics should be configured in Google Tag Manager, not by adding ad hoc scripts to the app.