From a fresh Ubuntu server to a healthy deployment with HTTPS and a tested backup, in order.
You need:
- An Ubuntu server you can reach over SSH as root, and a domain whose DNS you control.
- A checkout of your product on your machine, with Deno installed.
The examples use deploy@192.0.2.10 for the server and app.example.com for the product's host.
Run
getent passwd 1000as root. If it names a user (oftenubuntu), deploy as that user: use its name fordeployfrom here on.Make the deploy user with uid 1000, give it root's SSH keys, and turn off password and root logins. Pasting this block twice gives the same result as pasting it once:
{ id deploy >/dev/null 2>&1 || adduser --uid 1000 --disabled-password --gecos '' deploy; } && [ "$(id -u deploy)" = 1000 ] && mkdir -p ~deploy/.ssh && touch ~deploy/.ssh/authorized_keys && { grep -vxFf ~deploy/.ssh/authorized_keys ~/.ssh/authorized_keys \ >> ~deploy/.ssh/authorized_keys || [ $? -eq 1 ]; } && chown -R deploy:deploy ~deploy/.ssh && chmod 700 ~deploy/.ssh && chmod 600 ~deploy/.ssh/authorized_keys && printf 'PasswordAuthentication no\nPermitRootLogin no\n' \ > /etc/ssh/sshd_config.d/01-deploy.conf && systemctl reload ssh && sshd -T | grep -E '^(passwordauthentication|permitrootlogin) 'Check that the last command printed
notwice.From your machine, check that
ssh deploy@192.0.2.10works before you close the root session.As root, install Docker with its Compose plugin, rsync and unzip, and let the deploy user run Docker:
curl -fsSL https://get.docker.com | sh apt-get install -y rsync unzip usermod -aG docker deployAs
deploy, install Deno:curl -fsSL https://deno.land/install.sh | sh
The containers run as uid 1000 and read the web push keys, which only their owner may read. That is
why the deploy user needs that uid. The SSH file's name sorts first because SSH keeps the first
value it reads, and a provider's own file may turn passwords back on. Membership of the docker
group is as good as root, so keep the deploy user's key as safe as root's.
Only SSH, 80 and 443 may answer from outside. On the server, as root:
ufw allow OpenSSH && ufw allow 80/tcp && ufw allow 443/tcp && ufw --force enable
Docker opens the ports it publishes past ufw. Postgres, Valkey and Grafana stay private because
they are published on 127.0.0.1 only: never change DB_HOST_PORT, KV_HOST_PORT or
GRAFANA_HOST_PORT to 0.0.0.0. The provider's firewall outside the server, such as a Hetzner
Cloud Firewall, is the safer place for this rule, since Docker cannot open it.
After the first deploy, ss -tlnp as root must list no address but 127.0.0.1 and [::1] for
every port other than 22, 80 and 443.
Create an A record, and an AAAA record for IPv6, pointing at the server for every host:
| Host | When |
|---|---|
DOMAIN |
Always: the SPA, the API and file store. |
MPA_DOMAIN |
With the mpa profile. |
ADMIN_DOMAIN |
With the admin profile. |
each host in MPA_REDIRECT_RULE |
When the rule is set. |
Let's Encrypt checks each host over port 80, so the records must resolve before the first deploy.
dig +short app.example.com must answer 192.0.2.10.
On your machine, copy the example:
cp infra/envs/.env.example infra/envs/.env.prodSet the values below. Settings describes every variable.
Variable Value ENVprodPROJECTYour product's name, lowercase. DOMAIN,MPA_DOMAIN,ADMIN_DOMAINThe hosts from the DNS step. TRAEFIK_ACME_EMAILA real address for certificate notices. SSH_TO_SERVERdeploy@192.0.2.10PATH_ON_SERVERThe app's directory on the server. PROXY_HTTPS_HOST_PORT443PROXY_HTTP_HOST_PORT80COMPOSE_PROFILESAdd mpaandadminwhen you use them: The three apps.The proxy listens on
127.0.0.1unless you set the two ports, because in development it serves routes that make anyone an administrator.443and80publish on every IPv4 and IPv6 address;0.0.0.0:443would leave IPv6 visitors out.Leave
backupout ofCOMPOSE_PROFILESfor now: you set it up after the first deploy.Give every secret a value of its own:
Secrets Command DB_PASS,AUTH_COOKIE_SECRET,AUTH_PEPPER,S3_SECRET_ACCESS_KEY,GARAGE_RPC_SECRETopenssl rand -hex 32KV_PASSWORD,GRAFANA_PASSopenssl rand -hex 24S3_ACCESS_KEY_IDGKandopenssl rand -hex 12Encrypt the file into the repository, and keep its key in a password manager:
deno task env:encrypt
Mail, billing and the rest are optional. The deploy refuses a file that does not set ENV=prod,
or that keeps a secret from the example or leaves one empty, and names each.
The file stays on your machine. Every deploy copies it to infra/envs/.env on the server and
replaces what is there, so edit it here, never on the server. It holds the only copy of the secrets
and the file store's key, and the backup does not hold it (env files).
Anyone may make an account by default. To let in only people a member invited, set:
SIGN_UP=invite
Then every way of making an account asks for an invitation to a group: a password sign-up, a
sign-in code to an address with no account, and a first Google or GitHub sign-in. An invitation
tied to an address admits only that address; a link invitation admits anyone who has the link.
Without one, the API answers 403 with the reason SIGN_UP_CLOSED, and no account is made. People
who already have an account sign in as before. Guest accounts are off, as with GUEST_MODE=false.
The operator command and the demo seed still make accounts.
Signing up uses the invitation up, as accepting it does: the new person joins its group, and a
one-use invitation admits one account only. An invitation that is used up, revoked or expired, or
whose group is full, makes no account and answers SIGN_UP_CLOSED.
Until #913 ships, the sign-up screens send no
invitation. So for now invite closes sign-up to everyone; only the operator command line makes
accounts.
Any value other than open or invite stops the API at start-up with a message naming SIGN_UP.
Create the app's directory on the server:
ssh deploy@192.0.2.10 mkdir -p <PATH_ON_SERVER>Deploy from your machine:
deno task deployStart the shared proxy, once per server, in the app's directory on the server:
ssh deploy@192.0.2.10 cd <PATH_ON_SERVER> && deno task proxy:start
Every deploy applies pending migrations before the API and the worker start, and a failed migration
fails the deploy. deno task compose logs migrate on the server shows what it did. The deploy also
creates the web push keys when they are missing.
The proxy asks Let's Encrypt for each host's certificate as soon as it starts. When a host still shows a self-signed certificate after a few minutes, see HTTPS certificates.
On the server, list the containers. Each must be running, and healthy where it has a check:
deno task compose psFrom your machine, check the redirect:
curl -sI http://app.example.comCheck the API's health. It must print
"status":"ok"and200, without-k, which proves the certificate:curl -sS -w '\n%{http_code}\n' https://app.example.com/api/health
"down" with 503 means the API cannot reach the database. "degraded" with 200 means the cache or
the file store is down; its checks entry is false.
- Give operators access: the first operator, then sign in at
ADMIN_DOMAIN. - Backups: set them up, then rehearse a restore.
- Deploy an update