Moving From One Server to Kubernetes
A site that started on one server can move to Kubernetes with the template's pipeline. The same project deploys both ways, so nothing in the code changes. What moves is the data and the settings:
| On one server | On Kubernetes |
|---|---|
The database in the database container | An external PostgreSQL database (DATABASE_URL) |
Media in the media_data volume | A Cloud Storage bucket (GCLOUD_BUCKET), under GCLOUD_PATH_PREFIX |
Secrets and hosts in .env.prod | CI variables |
| Caddy's own Let's Encrypt certificates | cert-manager, through the ingress |
The move has two parts. Prepare the cluster while the old server still serves the site. Then, at a quiet time, close the old site, copy the last of its data and point DNS at the cluster.
Prepare the cluster
Follow Deploying a CWA Site up to, but not including, the production deploy: prepare the cluster, create the namespaces and set the CI variables. Two of them come from the server:
- The database: an external PostgreSQL database, with
DATABASE_URLandCWA_CI_POSTGRES_ENABLED="false"(see CI/CD Variables › Database). It must be PostgreSQL, like the server's. - The media bucket:
GCLOUD_JSON,GCLOUD_BUCKET, andGCLOUD_PATH_PREFIXif other apps share the bucket (see CI/CD Variables › Media storage). A Kubernetes deploy always stores media in the bucket.
Deploy the same commit the server runs, so both have the same migrations.
The secrets from .env.prod
Copy the values from the server's .env.prod into CI variables, so the new site keeps them:
.env.prod | CI variable | Why keep it |
|---|---|---|
JWT_SECRET_KEY, JWT_PASSPHRASE | The same names | Users stay signed in. A new key pair signs everyone out |
APP_SECRET | APP_SECRET | What Symfony signed with it, such as signed URLs, stays valid |
CADDY_MERCURE_JWT_SECRET | MERCURE_JWT_SECRET | The real-time cookies browsers hold stay valid |
NUXT_APP_SECRET | NUXT_APP_SECRET | OG image URLs already shared keep their signature |
ADMIN_PASSWORD | ADMIN_PASSWORD | The next fixture load would reset the admin to it |
MAILER_DSN, and MAILER_EMAIL if you set it | The same names | The site's mail relay |
SITE_HOST | KUBE_INGRESS_BASE_DOMAIN | Production is served at it |
SITE_ALIASES | KUBE_INGRESS_ALIAS_DOMAINS | Comma-separated here, not space-separated |
Leave JWT_PUBLIC_KEY out: the deploy derives it from the key and its passphrase, and fails if they don't belong together. Copy the key without the double quotes .env.prod puts around it. POSTGRES_PASSWORD isn't needed: the external database has its own.
For each secret that isn't a CI variable, the deploy keeps the release's live value, or generates a new one for a new release. So the first deploy of a site without these variables generates new ones. How to set them, protected and on GitHub as secrets, is in CI/CD Variables › Secrets.
Try it on staging
- Point
staging.<domain>at the cluster's ingress, and pushmain. Staging gets a certificate fromletsencrypt-staging, which browsers don't trust: that's on purpose (see Deploying a CWA Site › Pushmainand check staging). - Load a copy of the site's data into the new database, as in Copy the database, from a recent backup. Copy the media too (Copy the media).
- Check staging serves the site, with its images, and that you can sign in.
- Once staging's certificate was issued, set
CWA_CI_CLUSTER_ISSUER=letsencrypt-prod(see Deploying a CWA Site › Switch to trusted certificates). The production deploy then gets a trusted certificate.
On GitLab, staging gets production's variables, so it uses the same external database (see CI/CD › Staging). The final copy below replaces whatever staging wrote there.
Copy the media
Each backup's <time>-media.tar.gz holds the media_data volume: uploads, and their image variants under cache/. The bucket needs the same files at the same paths, under the site's GCLOUD_PATH_PREFIX. The database stores each file's path, not its URL, so the paths must match.
Unpack an archive and copy it with the Google Cloud CLI. Uploaded originals never change at a path, so they get a year's Cache-Control; variants get 30 days, as the API sets them when it stores a file itself (see Uploadable › Cache-Control on Stored Files):
mkdir media && tar -xzf backups/<time>-media.tar.gz -C media
gcloud storage rsync media gs://<bucket>/<prefix> --recursive \
--exclude='^cache/' --cache-control='public, max-age=31536000, immutable'
gcloud storage rsync media/cache gs://<bucket>/<prefix>cache --recursive \
--cache-control='public, max-age=2592000'
Leave <prefix> empty when GCLOUD_PATH_PREFIX is empty, and otherwise write it with its trailing slash, such as site-a/. If the server stored media under a prefix of its own, copy from that folder of the archive.
rsync only copies what changed, so copy once while preparing, and again from the final backup. Don't add --delete-unmatched-destination-objects: it would delete what the new site's staging stored.
Move the site
At a quiet time, ask editors to stop editing, then:
- Close the old site. On the server, turn maintenance mode on, so visitors see the maintenance page and nothing changes while you copy:
docker compose --env-file .env.prod exec php bin/console silverback:api-components:maintenance on
Admins bypass maintenance mode, so make sure nobody signed in keeps editing. - Take a final backup on the server with
bin/server/backup.sh, and copy its files off the server. - Copy the media again from the final backup's archive, as in Copy the media.
- Copy the database from the final backup's dump, as in Copy the database.
- Point DNS at the cluster's ingress: the site's host and every alias. With Cloudflare in front, leave them DNS only until the certificate is issued (see Cloudflare › Each deploy track).
- Deploy production (see Deploying a CWA Site › Deploy production). Don't load the fixtures: the admin and the pages came with the database. cert-manager gets the certificate once DNS reaches the ingress. Until then, the ingress serves a temporary one.
- Open the site. The dump was taken with maintenance mode on, and the setting is stored in the database, so the new site starts in maintenance mode too. In a ready API pod, flush the cache and turn it off, as
restore.shdoes on a server:kubectl get pods -n <namespace> -l app.kubernetes.io/name=cwa,app.kubernetes.io/instance=<release> kubectl exec -n <namespace> <ready pod> -- php bin/console silverback:api-components:purge-http-cache kubectl exec -n <namespace> <ready pod> -- php bin/console silverback:api-components:maintenance off - Check the site: pages come from the cache, images load from the bucket, and you can sign in with your existing password (see Deploying a CWA Site › Check the site).
Keep the old server running, still in maintenance mode, until DNS has moved everywhere: a visitor whose resolver still has the old address gets the maintenance page rather than an error. Then stop it, and keep its last backup and .env.prod.
Copy the database
Restore the dump into the external database with pg_restore, from anywhere that can reach it:
pg_restore --clean --if-exists --no-owner --dbname='postgresql://<user>:<password>@<host>/<database>' backups/<time>-database.dump
--clean --if-exists drops each table before restoring it, so it replaces a copy you loaded earlier. --no-owner makes the external database's user own everything. Use a pg_restore of the server's PostgreSQL major version or newer (the template's is 16). This is the same command as Migrations and Backups › Restoring a backup.
The first production deploy finds the database already migrated to the commit you deployed, so it has nothing left to migrate.
Kubernetes & Helm
Deploying the CWA stack to Kubernetes using Helm — values configuration, secrets management, migration Jobs, and rolling updates.
Scaled Mode
The template's opt-in scaled mode runs the API on two or more php pods, sharing one Redis cache and one Mercure hub, so losing a pod is not an outage. When you need it, how to turn it on, what it costs and what can go wrong.