Oops, something went wrong! Site admins have been notified.

In Part 8 we pushed the site to production on a Docker Swarm behind Traefik. Now comes the part nobody puts in the brochure: the next ten years. A site is not "done" when it launches — it is launched. The slow, unglamorous work that follows is what separates "we still run our site" from "we abandoned it three months in." This final post is the maintenance plan: updates, backups, security, and the one mental model that ties it all together.

Applying security updates

Drupal and its modules ship security releases. You want them, and you want them without surprises. On a containerized, recipe-driven site there are two valid ways to apply an update, and they serve different jobs.

  • The admin UI path — Drupal's built-in updater (/admin/reports/updates) can stage and swap module versions from the browser. It is fast for a one-off patch, but it needs the codebase to be writeable by PHP-FPM and leaves no diff to review. We don't lean on it.
  • The git + composer path — edit dependencies locally, run composer update, commit composer.lock, push, redeploy. Every change is reviewable in git, every change is reversible with git revert, and it works the same on every environment. This is the path we use.

The update procedure is deliberate. Snapshot first, then update the packages explicitly (listing them by name so composer only touches what you intend), then run the database updates:

composer update --no-interaction --with-all-dependencies drupal/core-recommended drupal/canvas drupal/eca drupal/klaro

Then apply any pending schema changes and clear caches:

docker compose exec php drush updatedb -y
docker compose exec php drush cr

Finally, prove it: composer audit should report no vulnerabilities, and a quick visual check of the homepage, navbar, and a blog post confirms nothing broke. One honest gotcha worth naming up front: right after a fresh install or a composer update, Drupal's update report can show modules as "out of date" for up to 24 hours — its cached release feed from drupal.org is simply stale. Don't panic. If you need a fresh answer immediately, force a re-check:

docker compose exec php drush state:set update.last_check 0 && docker compose exec php drush cron

How do you find out an update exists in the first place? Drupal cron emails you. The deploy pipeline sets the notification recipients (and throttles the check to weekly so you get a digest, not a daily nag) — which only works because the email pipeline itself is configured correctly, the subject of an earlier post.

Backups that restore themselves

A backup you have never restored is not a backup; it is hope. We run three layers so that hope never has to do any work.

  1. Committed snapshots live in git as the seed for a fresh build. They carry site structure, not editorial content — more on that split below.
  2. Weekly off-site backups run every Sunday at 03:00 UTC. The production crond service runs scripts/backup-offsite.sh, which dumps the live database, tars the uploaded files, and uploads both as dated assets to a GitHub Release tagged backups-chattanooga-prod. That copy lives entirely outside the Swarm cluster — it survives losing the whole server.
  3. Auto-reseed on redeploy is the layer that makes the other two effortless. When the production stack is rebuilt from scratch, the init container checks that GitHub Release first and restores the newest db-chattanooga-prod-*.sql.gz and files-chattanooga-prod-*.tar.gz it finds, falling back to the committed snapshot only if the release is missing or unreachable.

The practical payoff: in most disaster scenarios you do not run a manual restore at all. You delete the stack and its volumes, redeploy, and the init container pulls the most recent weekly backup on its own. The decision it makes is logged, so you can see in Portainer whether it used a release asset or the committed snapshot. If you ever do need a hand-restore, the assets are plain files you can pull with the GitHub CLI:

gh release download backups-chattanooga-prod -R TortoiseWolfe/Chattanooga-Digital -p 'db-chattanooga-prod-*.sql.gz'

Security hardening

A public site with a login form is a target. We ported a small, boring, effective account-security suite — boring being the highest compliment you can pay a security control. Each piece is a Drupal module wired in through the recipe and the deploy pipeline:

  • login_security — tracks failed login attempts per user and per IP, and emails a configured security address when something looks like a brute-force attempt. (The whole suite was added after a real suspicious-login alert, not as a checkbox exercise.)
  • captcha — a self-contained math CAPTCHA on the login form. No third-party service, no tracking, just enough friction to stop a script.
  • advban — a maintained IP-ban backend for shutting out an address that has clearly turned hostile.
  • Two-factor authentication via tfa and real_aes — time-based one-time codes (the authenticator-app kind), with the secret encrypted at rest.

One design choice here is worth dwelling on, because it is a lesson learned the hard way. We loosened the lockout: 2FA ships as optional, self-enroll rather than forced on every account. A forced-but-misconfigured second factor is a fantastic way to lock the site owner out of his own site permanently, because the encryption key that protects every enrolled secret is created once and must never change — regenerate it and every stored TOTP secret becomes undecryptable. So we make it available, encourage it, and let the owner turn it on for himself at /user/{uid}/security/tfa. Security that locks out the people it is meant to protect is not security; it is an outage with good intentions.

Content versus structure: the discipline that makes it forkable

Every site has two kinds of state, and confusing them is the root of most operational pain. Get this split right and almost everything else follows.

  • Structure lives in code, in the recipe YAML, in git. Content types, fields, Views, menus, Canvas page templates, roles and permissions, module and theme versions. This is developer-authored, reviewable, and rebuildable from scratch. A structure change is a recipe edit plus a commit plus a redeploy.
  • Content lives in the database. Blog posts, uploaded media, user accounts, form submissions. This is authored through the admin UI by people who should never have to touch git. A content change needs no deploy at all.

This is why the off-site backup and the recipe do different jobs. Recipes recreate the structure; the backup snapshot restores the content the recipes cannot possibly know about (the recipe has no idea what Greg blogged last Tuesday). On a full rebuild you restore the snapshot for content, then apply the recipe for structure — in that order, because the recipe is idempotent and won't stomp the content it finds.

There is one subtlety that bites everyone eventually. On staging and production the database is restored before the recipe runs, so any config the snapshot already carries is "already there." Drupal's recipe installer compares against active config and silently skips installing anything that already exists — which means editing a plain config file for an existing setting does nothing on a deployed site. To change a value that already shipped, you use a recipe config-action (which merges into active config on every apply) or a per-deploy drush cset in the deploy pipeline. New config that the deployed databases have never seen can ship as a plain file; everything already live must go through an action. Internalizing that one rule is the difference between "my change deployed" and "I patched it in the admin UI and it vanished on the next redeploy."

What's next

Nothing — and that is the whole point. Across this series we installed the site in Docker, learned what a Drupal Recipe is, built components, modeled real data, wired up a calendar and forms, automated the boring parts with cron, deployed to a production Swarm, and now we maintain it for the long haul. What you are left with is not a screenshot of a finished website. It is a recipe — a set of version-controlled files that rebuild a real, running, maintainable site from scratch, on your laptop or on a server, identically, every time.

So take it. The whole thing is open: clone the repository, run make up && make install && make build, and make it your own. That was the promise in Part 0 — we are, quite literally, giving away the recipes. Thanks for building it with us.

← Part 8: Deploy It

More insights

Want updates from Chattanooga.Digital?

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