How We Built It, Part 8: Deploy It

The live production site at www.chattanooga.digital, deployed via Portainer and Docker Swarm.

In Part 7: Cron + Automation we wired up the scheduled work that keeps the site healthy — cron, feed imports, off-site backups. Everything up to now has run on your laptop at localhost:8081. This post is about the last mile: taking it live so the rest of the world can reach it.

This is the part people are most nervous about, and it's worth saying up front: hosting a Drupal site is a solved problem. You have real choices, the trade-offs are clear, and the way we do it is reproducible enough that a stack delete plus redeploy rebuilds the whole site from git. This post stays conceptual — it's a map of the territory, not a DevOps course.

Three ways to host a Drupal site

Before our specific setup, here's the lay of the land. There are three realistic paths, and the right one depends on your budget, your team, and how much you want to manage yourself.

  • Managed Drupal (Pantheon, Acquia, Platform.sh) — you hand them your code and they run everything: servers, backups, scaling, security. Roughly $50–500/month. Lowest skill needed, highest cost. Best for agencies and compliance-heavy clients who want someone else on the hook.
  • VPS + Docker (a $6–50/month box at DigitalOcean, Hetzner, etc. running a Compose stack) — you rent one server and run the containers yourself. Medium skill, low cost. Best for small teams and cost-sensitive projects under ~100 daily visitors.
  • Self-managed Docker Swarm (a cluster behind Portainer + Traefik) — you run your own multi-site infrastructure. Higher skill, moderate cost. Best when you're hosting several sibling sites and want them to share one set of tools.

(There's a fourth path — old-school shared hosting with SFTP — but tight memory, lagging PHP versions, and no command-line access make it fragile for a real Drupal site. We skip it.)

Rule of thumb: under 100 daily visitors with low compliance needs, a VPS is plenty. Compliance plus a team, go managed. Several related sites, Swarm. We host more than one site, so we run Swarm.

How we deploy: Portainer + Docker Swarm

Chattanooga.Digital runs on Docker Swarm, managed through Portainer — a web UI for containers. Swarm is "Docker, but across a cluster," and Portainer is the dashboard you click instead of memorizing Swarm commands.

The local compose.yaml from Part 1 still describes the same three core services (database, PHP, web server), but production uses a different file — compose.production.yaml — with a few deliberate changes:

  • Named volumes instead of bind mounts. Bind mounts only work if the files happen to live on the exact node a container lands on; named volumes travel with the service across the cluster.
  • An init container. On every boot it clones the repo and populates the volumes, so the running site always reflects the latest main.
  • Traefik labels. A reverse proxy that routes public traffic to the right container and issues the HTTPS certificate automatically (more on that below).
  • Swarm secrets instead of .env values. Passwords like the database password live as encrypted Swarm secrets mounted at /run/secrets/ — never in a committed file or a log.

Deploying is a Portainer flow: Stacks → Add stack → Repository, point it at the GitHub repo and compose.production.yaml, paste in the environment variables, click Deploy the stack. A couple of minutes later the init container has populated the volumes and the site is up.

The magic part: SSL with no certbot

You never run certbot, never copy certificate files, never set a renewal reminder. Traefik watches for a container labeled with a hostname, asks Let's Encrypt for a certificate, Let's Encrypt validates over HTTP, and the cert is issued and stored — then renewed automatically before it expires. The whole chain is one router label in the compose file:

traefik.http.routers.chattanooga-prod.rule=Host(`www.chattanooga.digital`) || Host(`chattanooga.digital`)

That single line is the difference between "the certificate dance is a recurring chore" and "you never think about HTTPS again."

fix-settings: the service that patches every deploy

Here's the piece that makes a recipe-driven Drupal site deployable on these Wodby container images. The image rewrites settings.php on every boot from a template — handy, except it clobbers our database password and trusted-host config each time. Rather than fight that, we add a one-shot service called fix-settings that runs after the site comes up and reconciles everything that needs to be true after a fresh boot.

It runs the same canonical build sequence that make build runs locally, so dev and production stay in lockstep:

  1. composer update — pull security patches
  2. drush updatedb — apply any database schema updates
  3. drush recipe byte — the upstream base theme
  4. drush recipe chattanooga_digital — our content types, pages, calendar, forms
  5. drush php:script post_install.php — the cleanup that sets our homepage as the front page

On top of that build, fix-settings patches the database password back into settings.php, appends the trusted host patterns, configures SMTP so email actually sends, sets the site name and slogan from environment variables, restores our theme files (the Wodby image overwrites those too), and clears the cache. The whole thing is idempotent — running it again applies only what's missing and leaves existing content untouched.

That idempotence is the whole point. It's why the structure lives in recipes and not in a database dump: every deploy re-applies the recipe, so the live site can never silently drift from what's in git. (The flip side: never patch a live production site through the admin UI — those edits vanish on the next redeploy. Structural changes belong in the recipe.)

The orchestrator that runs all this lives in the repo as a set of small phase scripts under scripts/fix-settings/ — one per concern (settings, composer, recipe, SMTP, theme restore, and so on), sourced in order. Same scripts, both environments.

Staging → production, with a preview first

We run two separate Swarm clusters: staging (a disposable verification target that rebuilds 100% fresh from the recipe on every redeploy) and production (the real site). Every change lands on staging first; staging proves the recipe builds cleanly from scratch.

The cutover to production is two-phase, and the clever bit is the preview hostname. The production compose parameterizes its Traefik host rule through an environment variable, so we stand the production stack up on a temporary, already-resolving preview URL before the real DNS is pointed at it:

TRAEFIK_HOST_RULE=Host(`chattanooga-prod-preview.staging.chattanooga.digital`)

That gets us a real, Traefik-resolvable URL with a real Let's Encrypt cert to test against — the full test and accessibility suites run against it, the site owner reviews it, a contact form gets submitted to confirm email delivery. Only once everything's green do we point DNS at the production node and flip the env var back to its default:

TRAEFIK_HOST_RULE=Host(`www.chattanooga.digital`) || Host(`chattanooga.digital`)

Traefik picks up the new rule within seconds, Let's Encrypt issues the production cert on the first matching request, and the site is live at its real address. No blind cutover, no certificate scramble, no window where the public URL serves a half-built site. The full ordered checklist lives in docs/PRODUCTION_CHECKLIST.md.

What survives a redeploy

The reassuring part of all this discipline: deleting and recreating the stack is safe. The structure comes back from the recipe in git, the theme comes back from the init container's clone, and the content comes back from the newest weekly backup on GitHub Releases (with the committed snapshot as a fallback). The only way to lose content is to make database changes and never snapshot them — which is exactly why Part 7's backup cron exists.

What's next

The site is live, on HTTPS, rebuildable from git. In Part 9 we cover keeping it that way — the day-to-day of running a live Drupal site: updates, monitoring, backups you can actually restore, and the maintenance rhythm that keeps small problems from becoming outages.

Beta-test this post with your AI

This series is open-source documentation, and documentation drifts. Help us keep it honest: point your AI coding assistant (Cursor, Claude Code, or similar) at this post and the repo, and if it finds something that no longer matches the code, open a ticket. First time? Read A Token Is an Identity (in this series) so your AI opens issues as you, with credit. Edit the three variables at the top of the block below, then paste it into your assistant:

# ─────── EDIT THESE FIRST ───────
GITHUB_USERNAME = "your-github-username"   # your handle, so the issue is credited to YOU (see the Token Is an Identity post)
DEMO_REPO       = "TortoiseWolfe/gig-city-drupal"   # the repo to file against — or your own fork: GITHUB_USERNAME/gig-city-drupal
ARTICLE_URL     = "https://www.chattanooga.digital/blog/2026-06/how-we-built-it-part-8-deploy-it"
# ────────────────────────────────

Read ARTICLE_URL, then clone DEMO_REPO into my local environment and explore it.

Cross-check Part 8 (Deploy It) against the actual code — the Swarm/Traefik setup, the deploy pipeline, and the production cutover.

If anything is inaccurate, out of date, or contradicts the code, open an issue on
DEMO_REPO (authenticated as GITHUB_USERNAME) so the maintainers can fix the tutorial.

A good issue:
  - one problem per issue (not a catch-all list)
  - a clear title naming the section, e.g. "Part N:  no longer matches the code"
  - cite the exact spot: the post section + the repo file/path (and line if you can)
  - say what the post claims vs. what the code actually does
  - suggest the fix if it's obvious

← Part 7: Cron + Automation  ·  Part 9: Maintain It →

More insights

Want updates from Chattanooga.Digital?

Pre-join the co-op to receive new posts, workshop schedules, and member updates.