Installation
> New to Joinery? The Quick Start guide walks you through renting a server, configuring your domain, and installing Joinery step by step — no prior experience required.
Deploy Joinery on a fresh Ubuntu 24.04 or 26.04 LTS server, either in a Docker container or directly on the host (bare-metal). The same install.sh script handles both — the deployment mode is auto-detected from whether a port is supplied. Docker mode is how you put several sites on one machine; it is not a sandbox — see What Docker mode is.
Table of Contents
- Quick Start
- Prerequisites
- Password Security
- Docker Deployment
- Bare-Metal Deployment
- SSL Certificates
- Cloudflare Proxy Support
- Themes and Plugins
- Site Cloning
- Domain Management
- Site Management
- Maintenance Operations
- Troubleshooting
- Script Reference
Quick Start
One-liner install (latest version)
Docker:
mkdir -p /tmp/joinery && \
curl -sL https://getjoinery.com/utils/latest_release | tar xz -C /tmp/joinery && \
cd /tmp/joinery/maintenance_scripts/install_tools && \
sudo ./install.sh docker && \
sudo ./install.sh site mysite example.com 8080Bare-metal:
mkdir -p /tmp/joinery && \
curl -sL https://getjoinery.com/utils/latest_release | tar xz -C /tmp/joinery && \
cd /tmp/joinery/maintenance_scripts/install_tools && \
sudo ./install.sh server && \
sudo ./install.sh site mysite example.comOne-click deployment (Linode StackScript)
A StackScript installs Joinery while the instance first boots, so the deployer fills in a form and never opens a terminal. Select it when creating a Linode, answer the fields, and a few minutes later the site is running with SSL and a login.
The deploy form asks for as little as it can — every field is a chance for someone to abandon the form, and once this is a Marketplace listing each one is expensive to change:
| Field | Required | What it does |
|---|---|---|
| Admin email address | Yes | The admin account's address. Password reset needs a mailbox someone can receive at. |
| Admin password | Yes | The password for that account. Masked in the UI and kept out of the deployment log, which is what the password in its field name buys. It is the password the owner keeps; no change is forced at first sign-in. |
| Your domain | Yes | The domain the site is installed as. A site with no domain can get no certificate and every link it emits names an IP address, so the form does not offer that state. install.sh run by hand still detects the IP when no domain is given. |
| Linode API token | No | Only useful when the domain's nameservers point at Linode. Needs the Domains Read/Write scope. Creates the zone when the account holds none, then the A record, from the instance, so the first certificate attempt succeeds rather than the retry timer's. Never printed. Once it has proved usable it is sealed into the site for the one DNS publish of the mail records — made during the install when a sending key is supplied, otherwise by the setup wizard's email step — and deleted on use. |
| Email sending API key | No | Sets email up during the install, so the setup wizard's Email step opens on the delivery proof (or a DNS wait) instead of an empty form. The provider is told from the key: every provider one key configures declares the shape of its keys (SingleKeyProvider: SMTP2GO, Mailgun, SendGrid, Resend, Brevo, Postmark), and a key no shape matches is tried live against each. utils/install_mail_provider.php then runs the wizard's own ceremony: the From address is derived from the admin address on the site's domain, the owner's mailbox is provisioned for it, the domain is registered at the provider where its API allows, its mail records are published through the kept Linode token, and the provider is asked to verify. A key every provider rejects leaves nothing configured and the wizard asks again; the closing summary says which happened. Never printed. |
| Backblaze B2 bucket, key ID, key | No | Three fields that together point backups at a bucket during the install. utils/install_backup_target.php creates the target, fills the region and endpoint from Backblaze's own answer, tests the connection, and makes it the scheduled target — the same as the wizard's "Save and test". A bucket that cannot be reached is removed again and the wizard asks for one. The recovery key that turns nightly backups on is a secret shown once to a human and stays the wizard's. The key is never printed. |
user1 with root login disabled installs by hand with JOINERY_SSH_KEY in the environment, which the handoff script still honours. Region, plan and firewall are the deployer's own Create-form choices; a StackScript cannot preset or hide them, and every field it declares is shown.Neither optional service is a condition of the install. The StackScript only passes these fields on: _site_init.sh does the work, so a hand-run install.sh site takes the same inputs (see "Services set up at install" below).
Nothing is asked that can be worked out. The site name comes from the domain (or the instance ID); the install is always bare-metal, one site per instance.
There is no credentials file on this path — the owner already knows the password, because they chose it. Every other install writes one, since nobody chose that password.
An instance built this way is entirely the deployer's: no agent, no registration, no enrollment, no outbound call beyond fetching the release archive.
How it is put together. The script hosted at Linode is a wrapper of about twenty lines: it declares the fields, fetches the release archive, and hands off to maintenance_scripts/install_tools/linode_stackscript.sh inside it. All the real logic lives in the archive, so it ships with every release and an instance created today installs what was published this morning — with nothing to update on the Linode side. The pasted wrapper is kept in the repo at maintenance_scripts/install_tools/linode_stackscript_wrapper.sh so it stays reviewable.
If a step fails the script stops and says so in /var/log/stackscript.log, rather than continuing into a half-installed box that looks alive. The remedy is to destroy the instance and redeploy with the field corrected.
Manual transfer
# Copy the archive to the target server
scp joinery-X-Y.tar.gz root@YOUR_SERVER:~/
ssh root@YOUR_SERVER
tar -xzf joinery-X-Y.tar.gz
cd maintenance_scripts/install_tools
# One-time host setup
sudo ./install.sh docker # OR: sudo ./install.sh server
# Create your first site (password auto-generated — save it!)
sudo ./install.sh site mysite mysite.com 8080 # Docker (with port)
sudo ./install.sh site mysite mysite.com # Bare-metal (no port)The presence of a port signals Docker mode; omitting it signals bare-metal. To force either explicitly, use --docker or --bare-metal. The resolved mode is recorded in the site's Globalvars_site.php as deployment_environment (docker or baremetal) — the single source of truth the platform reads instead of probing for a container at runtime.
Prerequisites
Server requirements
- Fresh Ubuntu 24.04 or 26.04 LTS —
install.sh serverrefuses to run on anything else. It installs whichever PHP the release offers and derives every package, service, and config path from that, so no version is pinned; the gate is about which releases the package and service layout has been verified on. On an unverified release the setup can leave a server that does not work while looking like it installed. To proceed anyway and finish the setup by hand, pass--allow-unsupported-os; the check is not repeated byinstall.sh site, which presupposesserveralready ran. - Root access
- 1 GB RAM minimum
- 3 GB disk minimum
- Docker mode: port 8080+ available (or your chosen range)
- Bare-metal mode: ports 80 / 443 available
Archive contents
The joinery-X-Y.tar.gz archive contains:
public_html/— application codeconfig/— configuration templatesmaintenance_scripts/install_tools/— installer, Dockerfile, defaultsmaintenance_scripts/sysadmin_tools/— backup, restore, maintenance utilities
Password Security
Never use weak or example passwords in production. Auto-generation is the recommended path.
Auto-generated (recommended)
Omit the password and the installer generates a 24-character secure password, then displays it once at the end of installation:
sudo ./install.sh site mysite mysite.com 8080
# Output: "Auto-generated secure password: xK9mN2pQ7rT4vW8yB3cF6hJ1"Save the password immediately — it's also written to the site's Globalvars_site.php.
Bring your own password
Hand it over in a file so it never appears on a command line:
echo 'YourStr0ng&Secure#Pass@9' > /tmp/dbpass.txt
sudo ./install.sh site mysite --password-file=/tmp/dbpass.txt mysite.com 8080
rm /tmp/dbpass.txtAny character works
Quotes, backslashes, dollar signs, backticks, exclamation marks, spaces: all fine. The one value the password cannot hold is a line break, because it travels through single-line files.
The password is never pasted into a command. It reaches PostgreSQL as a quoted SQL literal on psql's standard input, reaches Globalvars_site.php through a PHP writer that emits a correct string literal, and reaches the database driver as a connection argument rather than part of a connection string.
What can still alter it is your own shell, before the installer runs: ! triggers history expansion in an interactive bash session, and $ and backticks expand inside double quotes. A file, or a single-quoted environment value, sidesteps that:
sudo POSTGRES_PASSWORD='It'"'"'s $12.50 & a "quote"!' ./install.sh site mysite mysite.com 8080Requirements
- Minimum 16 characters (24+ recommended)
- Mix of upper, lower, digits, symbols
- No dictionary words, personal info, sequential patterns, or example passwords
Non-interactive deployment
sudo ./install.sh -y docker
sudo ./install.sh -y -q site mysite mysite.com 8080-y accepts all prompts; -q suppresses progress output. Both flags work before or after the subcommand — write them first, as above, so every example reads the same way. An unknown flag stops the install with a message.
Without -y, a run with no terminal on stdin (cloud-init, CI, piped ssh) still completes: every prompt takes its default. Defaults are conservative — proposals (install Docker, use a suggested port) proceed; destructive choices (overwrite an existing site, delete data volumes, downgrade code) refuse, and only their explicit flags (--wipe-data, --allow-downgrade) can say otherwise. The one hard requirement is the bare-metal server setup's database password, which must arrive via POSTGRES_PASSWORD in the environment when nobody can type it.
Docker Deployment
What Docker mode is
Docker mode is how you run several Joinery sites on one machine. Each site gets a container holding a complete small server — Apache, PHP-FPM, PostgreSQL, cron, and whatever daemons its plugins need — with its own database, its own files and its own host port. The container's job is to keep sites from colliding with each other over ports, database names and file paths.
The container is not a security boundary, and a site is not a sealed appliance that can be dropped safely onto a shared machine. Treat every site on a host, and the host itself, as one trust domain:
- The sites share a network. Containers run on Docker's default bridge, so each one can reach the others, the host, and whatever else the host can reach. One site's code can open a connection to another site's PostgreSQL.
- The host is part of the site. HTTPS terminates on the host's Apache, which proxies to the container over plain HTTP on loopback. The certificate, the proxy vhost and the site's DNS all live outside the container.
dockeraccess is access to every site. Site data lives in named volumes on the host,docker execopens a root shell in any container, and membership of thedockergroup is equivalent to root on the machine.- Containers take Docker's defaults. No user-namespace remapping, no read-only root filesystem, no dropped capabilities. Processes start as root inside the container; Apache and PHP-FPM drop their workers to
www-data. - The site's web port answers on every interface.
-p PORT:80publishes there, and Docker's forwarding rules are consulted before UFW's, so a UFW rule does not close it — anyone who knows the port reaches the site directly, skipping the host proxy and its HTTPS redirect. The container's PostgreSQL port is the exception: it publishes on loopback only, andinstall.sh dockeradds aDOCKER-USERrule dropping ports 9080-9099 arriving on the public interface.
install.sh docker also does the host's housekeeping: fail2ban (see fail2ban and the real client address), a 100M cap on the system journal, Docker BuildKit garbage collection, 1G of encrypted swap, apport off, and cleared failed-login logs. It does not configure UFW on a Docker host — the UFW rules in this guide belong to bare-metal server setup.So run Joinery containers on a machine you control, and put sites that must not reach each other on separate machines rather than in separate containers.
One-time setup
sudo ./install.sh dockerChecks for Docker, installs Docker CE if missing, starts the daemon, verifies it's operational.
Create a site
sudo ./install.sh site SITENAME [DOMAIN_NAME] [PORT] [OPTIONS]| Parameter | Required | Default | Notes |
|---|---|---|---|
SITENAME | Yes | — | Site & database name (e.g., mysite) |
DOMAIN_NAME | No | Server IP | Domain for VirtualHost |
PORT | No | 8080 | Host port for web traffic |
- Validates parameters and archive structure.
- Checks port availability and suggests alternatives on conflict.
- Prepares an isolated build context.
- Builds the Docker image.
- Starts the container with persistent volumes.
- Verifies the site responds — the probe carries the configured domain in its
Host:header, so a green check means a visitor can load the site, not merely that Apache is up. A redirect to an HTTPS vhost the install did not create fails the check. - Optionally downloads stock themes/plugins (with
--themes). - Displays access info and the list of running containers.
Multi-site support
Each site needs unique ports. The installer detects conflicts and suggests the next pair:
| Site | Web port | DB port |
|---|---|---|
| site1 | 8080 | 9080 |
| site2 | 8081 | 9081 |
| site3 | 8082 | 9082 |
Volume mounts
| Volume | Container path | Purpose |
|---|---|---|
{site}_postgres | /var/lib/postgresql | Database files |
{site}_uploads | .../uploads | User uploads |
{site}_config | .../config | Site configuration |
{site}_backups | .../backups | Database backups |
{site}_static | .../static_files | Generated files |
{site}_logs | .../logs | Application logs |
{site}_cache | .../cache | Runtime cache |
{site}_sessions | /var/lib/php/sessions | PHP sessions |
{site}_apache_logs | /var/log/apache2 | Apache logs |
{site}_pg_logs | /var/log/postgresql | PostgreSQL logs |
Bare-Metal Deployment
One-time setup
sudo ./install.sh serverInstalls and configures PHP 8.3, Apache (with mod_rewrite), PostgreSQL, Composer, Certbot, UFW, fail2ban, SSH hardening, and unattended security updates.
How SSH hardening picks its account
Turning off root SSH login is the one hardening step that can lock an operator out, so the installer works out who will still be able to reach the box before it does that. Everything else — MaxAuthTries 3, empty passwords refused, idle-session timeouts, fail2ban, UFW — is applied unconditionally.
| What the installer finds | What it does |
|---|---|
Running as root, and /root/.ssh/authorized_keys has keys | Copies those keys to user1, grants it passwordless sudo, then sets PermitRootLogin no. |
Running under sudo from an ordinary account | That account already has its own key and sudo, so it sets PermitRootLogin no and does nothing else. |
| Neither — root reached by password, no key installed | Leaves PermitRootLogin alone and says so. Disabling it here would leave nothing able to log in. |
retire_install_password job.fail2ban and the real client address
maintenance_scripts/install_tools/host_housekeeping.sh is the one implementation of "fail2ban is configured". It is a core host installer, so it runs at install, on every converge of the host timer (_plugin_installers_start.sh, CORE_INSTALLERS), through the runner alone with sudo bash /var/www/html/SITE/maintenance_scripts/install_tools/_plugin_installers_start.sh --only=host_housekeeping.sh (the same lock and ownership check the timer's run gets, nothing else run), and by hand:
sudo bash /var/www/html/SITE/maintenance_scripts/install_tools/host_housekeeping.shWhat it leaves behind, every time: /etc/fail2ban/jail.d/joinery-sshd.local (the SSH jail: three failures in ten minutes, banned for an hour) and, where Apache is installed and logs the real client (the mod_remoteip step below is in place), jail.d/joinery-apache.local (apache-auth, apache-badbots, apache-noscript, apache-overflows, each on the file backend and watching /var/www/html/*/logs/ — a site's access.log/error.log and a Docker host's proxy_access.log/proxy_error.log — as well as /var/log/apache2/). Both are written whole, never appended to. Without the remoteip configuration an Apache log names the peer, which behind Cloudflare is an edge, so the Apache drop-in is removed rather than written and the output says why; the SSH jail is configured regardless. A /etc/fail2ban/jail.local that is jail.conf followed only by section headers, enabled lines and a ban policy (bantime, findtime, maxretry) for jails the drop-ins carry is deleted — that is a copy-and-append "enable fail2ban" in any wording, it repeats sections jail.conf already declares, and fail2ban 1.0.2 refuses to start on a repeated section; one with any other content is left alone and named in the output, with a warning when it repeats a section itself. The script then proves the service: it exits non-zero, with the journal's last lines, when fail2ban is not active afterwards or the sshd jail does not answer. SSH posture (PasswordAuthentication, PermitRootLogin) is read from sshd -T and reported, never changed.
The range list is read from the site tree the script lives in (SITE_ROOT/public_html/includes/cloudflare_ip_ranges.txt). On a Docker host, whose site trees are inside the containers, run it from a checkout of the code or name the tree: sudo bash host_housekeeping.sh "" /path/to/checkout.
A jail bans whatever address the log names, so the log must name the real client, and must name it only when every hop between the client and Apache is a proxy we know. The same script enables mod_remoteip with conf-available/joinery-remoteip.conf: X-Forwarded-For is trusted from Cloudflare's published edge ranges (public_html/includes/cloudflare_ip_ranges.txt, the one list SessionControl also trusts a CF-Connecting-IP from) and, inside a container, from the host's reverse proxy on the Docker bridge; the combined and vhost_combined log formats record that resolved client (%a). A forged forwarding header on a direct connection stops at the untrusted peer, so a ban can never be steered onto a third party. The Docker host's proxy vhost appends its peer to X-Forwarded-For rather than replacing it, so the container walks the chain right to left to the client. Behind an edge a ban is inert (the connections come from the edge); on a directly reached site it lands on the attacker.
Create a site
sudo ./install.sh site SITENAME DOMAIN_NAME [OPTIONS]Common options:
--admin-email=ADDRESS— the admin account's address. Set at the same moment as its password, so the only account on a new site is recoverable by email from the start. Omitted, the account is[email protected].--activate THEME— activate a specific theme after install--with-test-site— create a companion test site (bare-metal only)--upgrade-server=URL— fetch the code from somewhere other than the release site (see Where a site gets its upgrades)
- Verifies prerequisites (Apache, PHP, PostgreSQL).
- Deploys code to
/var/www/html/{sitename}/. - Runs
_site_init.shto create directories, configureGlobalvars_site.php, create the database, load the schema, record where upgrades come from, install Composer deps, install the default plugin bundle, and create the Apache VirtualHost. - Optionally creates a test site.
- Verifies the site responds.
What a new site comes with
A fresh install is not the bare platform. Drive and the personal calendar are core and always present; on top of them the installer turns on a bundle — a named set of plugins declared in install_bundles.json at the public_html/ root.
The default bundle is personal: mail and the AI assistant, which together with Drive and Calendar make the deployment a self-hosted replacement for the everyday Google tools. Everything else — events, commerce, bookings, the password vault, DNS filtering, server management — is installed from /admin/admin_plugins when it is wanted.
Both bundled plugins arrive installed and unconfigured, and each needs the owner to supply something before it does anything: mail needs MX and DKIM records and an outbound provider, the assistant needs a model provider.
# choose a different bundle at install time
JOINERY_INSTALL_BUNDLE=personal sudo ./install.sh site mysite mysite.com
# or apply one to an existing site
sudo php /var/www/html/{sitename}/maintenance_scripts/sysadmin_tools/install_bundle.php --list
sudo php /var/www/html/{sitename}/maintenance_scripts/sysadmin_tools/install_bundle.php --bundle=personalServices set up at install
Three things the setup wizard would otherwise ask for can be handed to install.sh site in the environment, on every path (bare metal, Docker, the StackScript). _site_init.sh honours them on fresh installs, after the plugin bundle, and none is a condition of the install: a failure is recorded and the wizard asks for that one again.
| Variable | What it does |
|---|---|
JOINERY_MAIL_API_KEY (+ JOINERY_MAIL_PROVIDER to name the provider, else detected from the key; JOINERY_MAIL_FROM, default derived from the admin address on the site's domain) | Email is set up by utils/install_mail_provider.php, the wizard's own ceremony: From address, the owner's mailbox, the domain registered at the provider where its API allows, its mail records published through the kept DNS credential, the provider asked to verify. The wizard then opens on the delivery proof. |
JOINERY_BACKUP_BUCKET + JOINERY_BACKUP_KEY_ID + JOINERY_BACKUP_KEY (+ JOINERY_BACKUP_PROVIDER b2/s3/linode, default b2; JOINERY_BACKUP_REGION for s3 and linode) | utils/install_backup_target.php creates the target, tests it, and makes it the scheduled one. The recovery key stays the wizard's. |
JOINERY_DNS_CREDENTIAL | The JSON utils/install_dns_credential.php takes, e.g. {"driver":"linode","credential":{"access_token":"…"}}, kept for the one publish of the mail records and deleted on use. |
JOINERY_MAIL_API_KEY=api-… \
JOINERY_BACKUP_BUCKET=joinerybackups JOINERY_BACKUP_KEY_ID=… JOINERY_BACKUP_KEY=… \
sudo -E ./install.sh site mysite mysite.comOutcomes land in {site root}/config/install_services.txt (mail=, backup=, dns_credential=, each done:, failed: or skipped:), which the closing summary reads; the first-task email notice is printed only when email was not set up. On Docker, every JOINERY_* input _site_init.sh reads crosses into the container from one list in install.sh (SITE_INIT_ENV_INPUTS). Each tool is CLI-only, reads only the environment, and can be run by hand on an existing site.
JOINERY_INSTALL_BUNDLE=none installs no plugins. Bundles are flat lists and never extend one another — they are alternative products rather than layers, so each names everything it wants.
Where a site gets its upgrades
Two separate things, which the installer keeps in agreement:
--upgrade-server=URLtells this run where to fetch the archive from. It defaults tohttps://getjoinery.com, the release site.upgrade_source, a setting on the finished site, tellsupgrade.phpwhere to fetch from every time after.
_site_init.sh writes the second from the first, so whatever a site was installed from is what it upgrades from. Nothing to configure and nothing to keep in sync: pass --upgrade-server and both follow, leave it off and the site tracks stable releases.Cloned sites are the exception — they carry the source site's upgrade_source, which is the right answer for a copy of that site.
Directory layout
/var/www/html/{sitename}/
├── public_html/ # Application code
├── config/ # Site configuration
├── uploads/ # User uploads
├── logs/ # Application logs
├── static_files/ # Generated files
└── backups/ # Database backupsSSL Certificates
SSL is configured automatically when a domain (not localhost or an IP) is provided.
How it works
- The installer checks whether the domain's DNS points to this server.
- If it does, Certbot runs to fetch a Let's Encrypt certificate.
- If it doesn't, the install goes ahead anyway and no certificate is issued. The vhost guards its
:443block with<IfFile>, so a missing certificate means the site serves HTTP rather than Apache refusing to start.
The retry timer
An install that could not issue a certificate leaves behind a systemd timer, joinery-ssl-retry@{domain}, that finishes the job whenever DNS lands — minutes later or a week later. Nothing needs to be run by hand.
Each run resolves the domain first and only invokes Certbot when the A record actually points at this server. That is what makes an open-ended retry safe: Let's Encrypt allows five failed validations per hostname per hour, and a DNS lookup that comes back empty costs nothing against that budget. On a CA-issued certificate the timer disables itself and removes its config.
sudo systemctl list-timers 'joinery-ssl-retry@*' # is one pending
sudo journalctl -fu [email protected] # what it is seeingIts state is a single file per domain at /etc/joinery/ssl-retry/{domain}.conf. Delete it to stop the retries.
To issue immediately rather than wait for the next check:
sudo /var/www/html/{sitename}/maintenance_scripts/sysadmin_tools/setup_ssl.sh mysite.example.comRequirements: domain DNS pointing here, port 80 reachable from the internet, Certbot installed (included in install.sh server).
Bare-metal
Certbot configures Apache directly:
sudo ./install.sh site mysite mysite.example.comDocker
The installer adds Apache on the host (if not present), creates a reverse proxy mysite.example.com → localhost:8080, then runs Certbot against the proxy:
sudo ./install.sh site mysite mysite.example.com 8080Skip SSL
sudo ./install.sh site mysite mysite.example.com --no-sslWhat --no-ssl gives you: a site that answers on plain HTTP for its domain. No HTTP→HTTPS redirect exists until a certificate does — the vhost's redirect is gated on the certificate file, so requests carrying the real domain load over HTTP rather than bouncing into a :443 vhost that isn't there.
Manual SSL later
sudo /var/www/html/{sitename}/maintenance_scripts/sysadmin_tools/setup_ssl.sh mysite.example.comWorks for both modes — Docker sites terminate TLS at the host's reverse proxy, which is the same Apache the script reloads. It tries an HTTP-01 challenge, falls back to DNS-01 when a provider credential file is present at /etc/letsencrypt/<provider>.ini, and leaves the site on HTTP if neither succeeds.
Cloudflare Proxy Support
The installer detects domains behind Cloudflare's proxy (orange cloud) by matching the resolved IP against Cloudflare's IP ranges, and adapts:
- Skips Let's Encrypt — Cloudflare provides edge SSL.
- Creates an HTTP proxy for Docker sites so Cloudflare can reach the origin.
| Mode | Browser ↔ Cloudflare | Cloudflare ↔ Origin | Origin cert |
|---|---|---|---|
| Flexible | HTTPS | HTTP | None required |
| Full | HTTPS | HTTPS (any cert) | Self-signed OK |
| Full (Strict) | HTTPS | HTTPS (valid cert) | Cloudflare Origin Certificate |
Themes and Plugins
By default, fresh installs include only the core application. Use --themes to download stock themes and plugins from the upgrade server during site creation:
sudo ./install.sh site mysite mysite.com 8080 --themesTo download themes and plugins after the site exists, use upgrade.php:
# Docker
docker exec mysite php /var/www/html/mysite/public_html/utils/upgrade.php
# Bare-metal
php /var/www/html/mysite/public_html/utils/upgrade.phpThe --themes flag uses the same distribution system as upgrade.php. See Deploy and Upgrade for the upgrade pipeline.
Site Cloning
Clone an existing site — database, uploads, settings — to a new server. The target machine pulls from the source.
Enable export on the source
clone_export_key is a managed setting: it is declared, but kept off the settings page so that an admin cannot turn on a full-site export from a browser. It is written in one of two ways.
A management node that provisions a clone of a site it manages arms the source itself, through the source agent's clone_export_arm primitive (the setting name is compiled into utils/clone_export_arm.php on the source; the plane sends only the key), and disarms it the same way when the provision ends. See the Server Manager docs.
By hand, in the source database:
UPDATE stg_settings SET stg_value = 'YourSecureRandomKey123' WHERE stg_name = 'clone_export_key';
-- When done:
UPDATE stg_settings SET stg_value = '' WHERE stg_name = 'clone_export_key';Use a strong random key (32+ chars, letters, digits, _ and -). HTTPS is required. Clear the key after cloning. Clone requests are logged on the source. The key is also the password the database dump is encrypted under in transit.
Run the clone
# Docker
sudo ./install.sh site newsite newdomain.com 8080 \
--clone-from=https://sourcesite.com \
--clone-key=YourSecureRandomKey123
# Bare-metal
sudo ./install.sh site newsite newdomain.com \
--clone-from=https://sourcesite.com \
--clone-key=YourSecureRandomKey123What gets cloned
| Item | Behavior |
|---|---|
| Database (all tables) | Exact copy from source |
| All settings | Exact copy from source |
| Uploads directory | Exact copy from source |
| User accounts | Preserved from source |
clone_export_key | Cleared on the new site |
Globalvars_site.php | Regenerated with new DB credentials |
| Themes & plugins | Downloaded from the source site |
Process
- Pre-flight: source reachable, key valid.
- Display manifest: DB size, uploads size, themes/plugins.
- Confirmation prompt (skip with
-y). - Deploy application code.
- Stream encrypted, compressed database; restore.
- Stream compressed uploads; extract.
- Update site URL.
- Standard setup: Composer, permissions, SSL.
Domain Management
Use manage_domain.sh (in maintenance_scripts/sysadmin_tools/) to add, change, or remove domains on existing sites. Works for both Docker and bare-metal.
cd maintenance_scripts/sysadmin_tools
# Current state
sudo ./manage_domain.sh status mysite
# Assign a domain (with SSL via Let's Encrypt unless Cloudflare detected)
sudo ./manage_domain.sh set mysite example.com
# Without SSL (e.g. Cloudflare-proxied or testing)
sudo ./manage_domain.sh set mysite example.com --no-ssl
# Revert to IP-only access
sudo ./manage_domain.sh clear mysite
# Restore the previous configuration
sudo ./manage_domain.sh rollback mysite
# Remove SSL only, keep the domain
sudo ./manage_domain.sh remove-ssl mysiteFor Docker sites, set creates an Apache reverse proxy on the host and disables 000-default.conf so bare-IP requests don't fall through to Ubuntu's welcome page.
Site Management
Docker container lifecycle
docker stop mysite
docker start mysite
docker restart mysite
docker ps --filter "name=mysite"Logs
# Docker
docker logs mysite # Startup
docker logs -f mysite # Follow
docker logs --tail 100 mysite # Last 100
docker exec mysite tail -100 /var/www/html/mysite/logs/error.log
# Bare-metal
tail -f /var/www/html/mysite/logs/error.log
tail -f /var/log/apache2/access.logShell access
# Docker
docker exec -it mysite bash
# Bare-metal — just use the host shell
cd /var/www/html/mysite/Apache management
In Docker, never service apache2 restart — it kills the container. Use reload or graceful:
docker exec mysite service apache2 reload
docker exec mysite apache2ctl graceful
docker exec mysite apache2ctl configtestBare-metal:
sudo systemctl reload apache2
sudo apache2ctl configtestPostgreSQL access
# Docker
docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" mysite \
psql -h 127.0.0.1 -U postgres -d mysite
# Bare-metal
psql -U postgres -d mysiteMaintenance Operations
Database backup and restore
# Backup (Docker)
docker exec mysite pg_dump -U postgres mysite | gzip > backup.sql.gz
# Backup (bare-metal)
./maintenance_scripts/sysadmin_tools/backup_database.sh mysite
# Restore (Docker)
gunzip -c backup.sql.gz | docker exec -i mysite psql -U postgres -d mysite
# Restore (bare-metal)
./maintenance_scripts/sysadmin_tools/restore_database.sh mysite backup.sqlRun backup_database.sh with no database name and it backs up every database on
the machine, except any that a site's config names as its dbname_test. A test
database holds no content of its own — it is rebuilt from live on demand — so
backing one up would ship a second encrypted copy of the site's data for
nothing. The skips are listed in the run's output. The match is exact and a
database that is some site's live dbname is never skipped, so a separate test
site ({site}_test, with real content of its own) is always backed up.
Update application code
Docker — stop and re-create the container; volumes persist:
docker stop mysite && docker rm mysite
tar -xzf joinery-NEW-VERSION.tar.gz
cd maintenance_scripts/install_tools
sudo ./install.sh site mysite mysite.com 8080The container detects this isn't a fresh install and skips initial setup.
Bare-metal — use upgrade.php:
php /var/www/html/mysite/public_html/utils/upgrade.phpFor more detail on the upgrade pipeline, see Deploy and Upgrade.
Run database migrations
# Docker
docker exec mysite php /var/www/html/mysite/public_html/utils/update_database.php
# Bare-metal
php /var/www/html/mysite/public_html/utils/update_database.phpRemove a site
remove_account.sh detects whether the site is Docker or bare-metal and handles both:
sudo ./maintenance_scripts/sysadmin_tools/remove_account.sh mysite
sudo ./maintenance_scripts/sysadmin_tools/remove_account.sh mysite -y # No prompt| Docker sites | Bare-metal sites |
|---|---|
| Docker container | Website directories |
| All Docker volumes (postgres, uploads, etc.) | Test site directories |
| Docker image | Apache VirtualHost |
| Build directory | PostgreSQL database |
Troubleshooting
Container won't start
docker logs mysiteCommon causes: port already in use (the installer normally detects this and offers alternatives), volume permission issues, or out of disk space.
Services not running after a host restart
The container's CMD should bring services up automatically. If not:
docker exec mysite service postgresql start
docker exec mysite service apache2 startPermission errors (bare-metal)
sudo chown -R www-data:user1 /var/www/html/mysite
sudo chmod -R 775 /var/www/html/mysite
# Or:
./fix_permissions.sh mysite --productionDatabase load failure during install
Almost always a syntax error, or a password the shell altered on its way in.
- If the password was typed on a command line, pass it with
--password-fileorPOSTGRES_PASSWORD=instead. See Any character works. - Verify any locally-modified
joinery-install.sql.gzfor SQL syntax. - Confirm UTF-8 encoding on the SQL file.
pg_hba.conf settings, authentication method, and database user permissions are not the cause — the installer handles all of those.Debugging:
docker logs mysite 2>&1 | grep -i "error\|fail"
docker exec -it mysite bash
su postgres -c "psql -d mysite -c '\\dt'"Composer autoload errors after cloning
The composerAutoLoad setting was copied from the source and points to an invalid absolute path. Set it back to the portable relative path:
# Docker
docker exec -it mysite bash
PGPASSWORD='your_db_password' psql -U postgres -d mysite \
-c "UPDATE stg_settings SET stg_value = '../vendor/' WHERE stg_name = 'composerAutoLoad';"
# Bare-metal
sudo -u postgres psql -d mysite \
-c "UPDATE stg_settings SET stg_value = '../vendor/' WHERE stg_name = 'composerAutoLoad';"Port conflict handling
If the chosen port is in use, the installer shows existing Joinery containers and suggests the next available port pair, then prompts you to accept.
Script Reference
install.sh
| Subcommand | Purpose |
|---|---|
install.sh docker | Install Docker (one-time) |
install.sh server | Set up bare-metal host (one-time) |
install.sh site … | Create a new Joinery site |
install.sh list | List existing sites |
| Flag | Description |
|---|---|
-y, --yes | Auto-accept all prompts (non-interactive) |
-q, --quiet | Suppress progress output; show errors and final summary |
install.sh site options:install.sh [-y] [-q] site [--docker|--bare-metal] SITENAME [DOMAIN] [PORT] [OPTIONS]
--password-file=FILE Read database password from file (recommended)
--activate THEME Activate this theme after install
--with-test-site Create a companion test site (bare-metal only)
--themes Download stock themes/plugins from upgrade server
--no-ssl Skip automatic SSL setup
--clone-from=URL Clone DB + uploads from an existing site
--clone-key=KEY Authentication key for clone sourceIf no password is given (and no --password-file), the installer auto-generates a 24-character password.
Supporting scripts
| Script | Purpose | Called by |
|---|---|---|
_site_init.sh | Internal site initialization (DB, config, Composer) | install.sh site, Dockerfile CMD |
fix_permissions.sh | Sets ownership and permissions on site files | _site_init.sh, manual |
Dockerfile.template | Template for building Docker images | install.sh site (Docker) |
default_Globalvars_site.php | Template for site configuration | _site_init.sh |
default_virtualhost.conf | Template for Apache VirtualHost | _site_init.sh |
_site_init.sh is internal — don't invoke it directly. Use install.sh site.Sysadmin tools
Located in maintenance_scripts/sysadmin_tools/:
| Script | Purpose |
|---|---|
manage_domain.sh | Domain management: set, clear, status, rollback, remove-ssl |
backup_database.sh | Backup PostgreSQL database |
restore_database.sh | Restore PostgreSQL database |
backup_project.sh | Full site backup (files + database) |
restore_project.sh | Full site restore |
copy_database.sh | Copy database between sites |
remove_account.sh | Remove a site completely |
Reverse proxy for production (multiple Docker sites on 80/443)
For multiple Docker sites sharing standard ports, install Apache on the host:
apt-get install -y apache2
a2enmod proxy proxy_http headers ssl rewrite
systemctl restart apache2Create /etc/apache2/sites-available/yoursite.conf:
<VirtualHost *:80>
ServerName yoursite.com
ServerAlias www.yoursite.com
ProxyPreserveHost On
ProxyRequests Off
ProxyPass / http://127.0.0.1:8080/
ProxyPassReverse / http://127.0.0.1:8080/
RequestHeader set X-Real-IP %{REMOTE_ADDR}s
RequestHeader set X-Forwarded-For %{REMOTE_ADDR}s
RequestHeader set X-Forwarded-Proto "http"
</VirtualHost>Enable and add SSL:
a2ensite yoursite
systemctl reload apache2
apt-get install -y certbot python3-certbot-apache
certbot --apache -d yoursite.com -d www.yoursite.comRelated Documentation
- Deploy and Upgrade — Upgrade pipeline and
upgrade.php - Publish/Upgrade System Analysis — How upgrade archives are built and distributed
- Server Manager — Remote node management and applying upgrades via the admin UI
- Settings — Configuring a site after installation