Deploy to One Server
bin/server/ scripts set up, deploy, back up and restore a site, but they haven't run a production site yet. Kubernetes is the supported way to run a site (see Deploying a CWA Site).Every project made with create-cwa has the scripts in bin/server/. They run compose.prod.yaml on one server, and do for it what the Kubernetes pipeline does for a cluster: build, migrate, purge, generate image variants and warm the cache. They also back the site up and restore it.
| Script | What it does |
|---|---|
bin/server/init | Writes the site's settings and secrets to .env.prod, once |
bin/server/deploy.sh | Builds the images and deploys the checkout. --fixtures loads the fixtures too |
bin/server/backup.sh | Backs up the database, the media and the TLS certificates to backups/ |
bin/server/restore.sh | Puts a backup back |
bin/server/cron.example | The scheduled jobs to copy into the server's crontab |
What you need
- A Linux server, such as Debian or Ubuntu, from any provider. The deploy builds the images on the server, so it needs room to build as well as to serve.
- Docker Engine with the Compose plugin (
docker compose), plusgit,curlandopenssl. Run the scripts as a user that can rundocker. - A domain whose DNS you can change.
- A mail relay that takes SMTP, for the emails the site sends (password resets, the orphan scan).
Prepare the server
- Point the domain at the server. Create an
A(andAAAA) record for the site's host, and for each other host it should answer to. Caddy, inside the php container, gets a Let's Encrypt certificate for each of them on its own, so the records must reach this server before the first deploy. - Open the ports.
80/tcp(Let's Encrypt's challenge and the redirect to HTTPS),443/tcp, and443/udpfor HTTP/3. - Clone your project on the server, for example to
/srv/my-site:git clone <your project's repository> /srv/my-site cd /srv/my-site
The scripts can be run from anywhere: each one moves to the checkout's root first.
Write the settings with init
bin/server/init
It asks three questions, unless the variable is already set in the environment:
| Setting | Question | Example |
|---|---|---|
SITE_HOST | The site's domain | www.example.com |
SITE_ALIASES | Other domains served the same, separated by spaces. Optional | example.com |
MAILER_DSN | Your mail relay as a Symfony Mailer DSN | smtp://user:pass@smtp.example.com:587 |
A host must be a bare name: no scheme, port or path. No answer may contain a single quote. To run it unattended, set all three first:
SITE_HOST=www.example.com SITE_ALIASES=example.com MAILER_DSN='smtp://user:pass@smtp.example.com:587' bin/server/init
It writes .env.prod at the root of the checkout, readable only by you (mode 600):
- your three answers;
COMPOSE_FILE=compose.yaml:compose.prod.yaml, so Compose uses the production file and never the dev override;- new random values for
APP_SECRET,NUXT_APP_SECRET,CADDY_MERCURE_JWT_SECRET,POSTGRES_PASSWORDandADMIN_PASSWORD; - a new 4096-bit JWT key pair:
JWT_SECRET_KEY,JWT_PUBLIC_KEYandJWT_PASSPHRASE.
It ends by printing the admin login, admin / <password>. The password is also ADMIN_PASSWORD in .env.prod.
.env.prod holds the site's secrets. Keep a copy somewhere safe, off the server, and never commit it (the template's .gitignore leaves it out). init never overwrites it: run again, it stops with .env.prod already exists: delete it first to start again (its secrets are the site's). A new JWT key pair signs every user out.Add any other setting compose.prod.yaml passes to php to .env.prod yourself, such as MAILER_EMAIL (the address the site sends from, and where the orphan scan reports), ADMIN_EMAIL or ERROR_MAIL_TO (see Docker › Production Docker Compose).
Uploaded media is stored on the server, in the media_data volume, and served at https://<SITE_HOST>/uploads/. To store it in a Cloud Storage bucket instead, set MEDIA_STORAGE=gcloud and the bucket's settings in .env.prod (see Docker › Production Docker Compose).
The first deploy
bin/server/deploy.sh --fixtures
--fixtures loads the fixtures, which create the admin user and any pages your scaffold seeds. Use it on the first deploy. The load appends: it creates what's missing and keeps what exists. Every load also resets the admin's password to ADMIN_PASSWORD, so leave it off later deploys.
When it's done, the log ends with ✅ Deployed https://<SITE_HOST>. Sign in at /login with the login init printed.
deploy.sh --fixtures run in a terminal: the build, the fixture load, the image variants and the page warm, ending with "Deployed".What a deploy does
deploy.sh can be run again at any time. In order, it:
- Builds the images (
docker compose build --pull), on the server, from the checkout. - Starts the database.
- Checks for pending migrations, when the site is already deployed. It runs the template's migrations plan with the new php image, in a one-off container with a read-only database session, and prints
Pending migrations: <n>. A failed plan stops the deploy before anything restarts:❌ The migrations plan failed: nothing was deployed. - Backs up first when migrations are pending, with
backup.sh. A failed backup stops the deploy too. - Starts the front end, then the API. The app container starts first, so no old front end renders into the new API's cache. php migrates the database as it starts. Each container whose image changed is recreated, so the site is down for the few seconds php takes to restart.
- Loads the fixtures, with
--fixtures. - Purges the rendered pages, which point at the old build's
/_nuxtfiles, and warms/_api(see CI/CD › Why every deploy clears the page cache). - Generates missing image variants in one-off php containers, never in the php that serves the site. Each is capped at
CWA_SERVER_IMAGE_VARIANTS_MEMORY(1536m). A container that stops at its own memory limit (CWA_SERVER_IMAGE_VARIANTS_MAX_MEMORY,1G) hands over to another, up to 50. When it made any, the deploy flushes the HTTP cache and warms/_apiagain, so cached responses list the new variants. - Warms the page cache with every page in the sitemap. The requests go to the server itself (
127.0.0.1), whatever DNS says. A failed warm only warns: pages render on their first visit instead.
The settings that change these steps are in CI/CD Variables › Single-server scripts.
Later deploys
Pull the new code, then deploy without --fixtures:
git pull
bin/server/deploy.sh
A deploy only migrates forward. To go back to an older commit after a migration ran, restore the backup taken before it (see Restore a backup).
Schedule the jobs
bin/server/cron.example has the site's scheduled jobs. Copy them into crontab -e for the user that runs Docker, with the path to your checkout in place of /srv/my-site:
# Daily backup at 02:30
30 2 * * * cd /srv/my-site && sh bin/server/backup.sh >> backups/backup.log 2>&1
# Then copy backups/ off the server
0 3 * * * rsync -a --delete /srv/my-site/backups/ backup-host:/backups/my-site/
# Daily orphan scan at 03:00
0 3 * * * cd /srv/my-site && docker compose --env-file .env.prod exec -T php bin/console silverback:api-components:scan-orphaned >> backups/orphan-scan.log 2>&1
- The backup keeps the newest five in
backups/(see Back up). - The copy off the server is commented out in the file, because only you know where it goes. Use
rsync,rcloneor your provider's tool. Don't leave it out: a backup on the same disk as the site is lost with it. - The orphan scan refreshes the admin's Orphaned Resources report, and emails
MAILER_EMAILwhen the orphans have changed. It never deletes anything (see Console Commands › scan-orphaned).
The times are the server's local time.
Back up
bin/server/backup.sh
Each backup is three files in backups/, named after the time it started, in UTC:
| File | What it holds |
|---|---|
<time>-database.dump | The database, from pg_dump --format=custom. It's kept only if pg_restore --list reads it back |
<time>-media.tar.gz | The media_data volume: uploads and their image variants |
<time>-caddy-data.tar.gz | Caddy's data, which holds the site's TLS certificates |
<time> looks like 20261024T031500Z. It keeps the newest five backups and deletes older ones, counting the ones deploy.sh takes before migrations too. Only files named in this form are ever deleted. The folder is readable only by you.
backups/ goes with it. Copy it somewhere else every day, and keep that copy private: the dump holds every user's email address and password hash, and Caddy's data holds the certificates' private keys. Keep .env.prod with it, because a backup doesn't include it.With MEDIA_STORAGE=gcloud, uploads are in the bucket, not on the server, so the media archive is empty and the bucket isn't backed up by these scripts.
Restore a backup
bin/server/restore.sh # the newest backup
bin/server/restore.sh 20261024T031500Z # a given one
Restoring replaces the database and the media. Everything since the backup is lost. So it first asks you to type the site's host; --yes skips the question. Then it:
- Turns maintenance mode on, so visitors see the maintenance page.
- Restores the database.
- Turns maintenance mode on again, because the setting is stored in the database and the backup brought back its own.
- Replaces the media with the backup's.
- Restarts php, which migrates a database restored from an older version of the site, then purges the HTTP cache.
- Turns maintenance mode off.
--certificates also restores Caddy's data. Use it when a new server takes over the site, so it serves the old certificates straight away:
- On the new server, clone the project and copy
.env.prodfrom the old one, rather than runninginit. - Run
bin/server/deploy.sh, without--fixtures. - Copy the backup's three files into
backups/. - Run
bin/server/restore.sh <time> --certificates. - Point the DNS at the new server.
Running commands by hand
Run Compose with the site's settings: --env-file .env.prod, from the checkout's root. COMPOSE_FILE in that file picks the production files.
docker compose --env-file .env.prod ps
docker compose --env-file .env.prod logs -f php
docker compose --env-file .env.prod exec php bin/console silverback:api-components:maintenance on
docker compose down -v.-v deletes the volumes: the database, the media and the certificates.Moving to Kubernetes
When one server isn't enough, see Moving From One Server to Kubernetes.
Docker
The Docker Compose stack for local development and single-server production (a preview) — services, volumes, environment variables, the images and their build stages, and how Caddy caches pages.
Kubernetes & Helm
Deploying the CWA stack to Kubernetes using Helm — values configuration, secrets management, migration Jobs, and rolling updates.