# EFET — deploy on the Portainer server

Two Portainer **Git stacks**, one per repository:

| Stack | Repo | Compose path | Contains |
|---|---|---|---|
| `efet-public-backend` | `efet-public` | `docker-compose.staging.yml` | Laravel CMS + MySQL |
| `efet-public-frontend` | `efet-frontend` | `docker-compose.staging.yml` | Nuxt SSR |

Portainer clones each repository itself, so the build context is just `.`, the
Bitbucket credential lives in Portainer's stack configuration rather than in any
committed file, and each repo owns the compose that deploys it.

The two stacks share no network. The frontend reaches the CMS over the public
API URL through Traefik, exactly as it does from a browser, so only a handful of
values have to agree between them (listed in `deploy/frontend.env.example`).

Everything is done from the Portainer UI — no SSH to the host is needed.

---

## 1. Bitbucket tokens

Each stack needs read access to its own repository. In **each** repo:
**Repository settings → Security → Access tokens → Create Repository Access
Token**, name it `portainer-deploy`, tick **Repositories: Read**, and copy the
value — it is shown only once.

A single workspace access token would cover both, but workspace and project
tokens are a Bitbucket **Premium** feature; repository tokens work on every plan
and are better scoped anyway.

## 2. Traefik

Both stacks join Traefik's network, which must already exist — they do not
create it. On the EAE server that network is **`web`** (the one with no stack in
Portainer → **Networks**). Every other stack there has the same shape: its own
`<stack>_internal` plus `web`.

Confirm the `certificatesresolvers` name under **Containers → traefik →
Inspect** and adjust `TRAEFIK_CERTRESOLVER` if it is not `letsencrypt`.

## 3. APP_KEY

Generate it **once** and never change it — everything the app encrypted with the
old key, and every signed URL, becomes unreadable:

```bash
echo "base64:$(openssl rand -base64 32)"
```

## 4. Deploy the backend stack

**Stacks → Add stack → Repository**, name it `efet-public-backend`:

| Field | Value |
|---|---|
| Repository URL | `https://bitbucket.org/eae-team/efet-public.git` |
| Authentication | on — username `x-token-auth`, password = the repo access token |
| Reference | `refs/heads/staging` |
| Compose path | `docker-compose.staging.yml` |

Under **Environment variables → Advanced mode**, paste a filled-in copy of
`deploy/backend.env.example`. Deploy.

The first build takes a few minutes. Wait for `efet-public-backend-backend-1`
and `-db-1` to report **healthy**.

## 5. Deploy the frontend stack

Same thing, name `efet-public-frontend`, repository
`https://bitbucket.org/eae-team/efet-frontend.git`, its own access token, and
`deploy/frontend.env.example` as the variables.

The Nuxt build needs a few minutes and ~2 GB of RAM. Until the database is
imported the site 404s on every route — that is expected, not a broken deploy.

## 6. Redeploying with new code

**"Pull and redeploy" alone is not enough.** It re-clones the repository, but
Compose only builds when the image is missing, so it redeploys the image it
already has. The *Re-pull image* toggle does not help either: it pulls from a
registry, and there is none here. This is a known Portainer limitation
([#12508](https://github.com/portainer/portainer/issues/12508),
[#6897](https://github.com/portainer/portainer/issues/6897)).

So a deploy is:

1. Push to `staging`
2. **Stacks → the stack → Environment variables**: set a new `IMAGE_TAG`
3. **Pull and redeploy**

A tag that does not exist yet is what forces the build, deterministically.

### The tag convention

`staging-YYYY-MM-DD-HHMM`, with the time of the deploy:

```
staging-2026-08-27-0930
staging-2026-08-27-1615     <- same day, second deploy
```

**Include the time, not just the date.** Two deploys in one day under the same
tag is the one way to get a confusing result: the image already exists, Compose
skips the build, and the stack reports success while running the morning's code.

Rollback is free: set `IMAGE_TAG` back to an earlier tag and redeploy — the
previous images are still on the server, so it takes seconds and builds nothing.

The two stacks are redeployed independently. A backend-only change does not
touch the frontend.

### Old images

Each stack has an `imagecleanup` service that runs once per deploy and keeps
only the `IMAGE_KEEP` most recent builds of its own image (default 3), deleting
the rest. It counts builds rather than days on purpose: several deploys in one
day must not pile up — at ~1.2 GB per backend image that adds up fast on a
shared server. It is scoped to `IMAGE_NAME`, so neither stack ever touches the
other's images or those of the sibling EFET stacks, and docker refuses to remove
an image a container is using.

It needs the docker socket, which is root-equivalent on the host. If you would
rather not have that in the stacks, delete the `imagecleanup` service from both
compose files and prune by hand from **Images → Unused → Remove**.

## 7. Import the existing database

The stack's MySQL starts empty and `AUTO_MIGRATE=true` only creates the schema.
Take a **fresh** dump from the current server.

The dump replaces the migrated schema wholesale (`mysqldump` writes
`DROP TABLE IF EXISTS` before each `CREATE TABLE`) and carries the `migrations`
table with it, so afterwards the database is exactly the old server's state —
including the backoffice users, so the existing admin logins keep working.

The backend image ships `curl` and the mysql client, so its own console is
enough. Put the dump somewhere reachable over HTTP (the old server's public
directory works — use an unguessable name and delete it right after), then
Portainer → **Containers → efet-public-backend-backend-1 → Console → /bin/bash**:

```bash
curl -o /tmp/dump.sql https://old-server.example.org/xxxxx.sql
mysql -h "$DB_HOST" -u "$DB_USERNAME" -p"$DB_PASSWORD" "$DB_DATABASE" < /tmp/dump.sql
rm /tmp/dump.sql
```

**Then restart the backend container.** The dump's `migrations` table is from
the day it was taken, so any migration merged after that has not run yet.
`AUTO_MIGRATE` applies them on start — but only on start, so the import alone
does not bring the schema up to date. **Containers → … → Restart**, then check
the log: it should list the migrations it applied, or say there was nothing to
migrate.

## 8. Import the uploaded media

Media is **not** in the dump. Two directories matter, and they map to the two
volumes:

| On the old server | In the container |
|---|---|
| `storage/app/public/uploads` | `/var/www/html/storage/app/public/uploads` |
| `storage/app/private` | `/var/www/html/storage/app/private` |

Tar them up, publish them the same way, and from the backend console:

```bash
curl -o /tmp/media.tar.gz https://old-server.example.org/xxxxx-media.tar.gz
tar -xzf /tmp/media.tar.gz -C storage/app/public/uploads
curl -o /tmp/private.tar.gz https://old-server.example.org/xxxxx-private.tar.gz
tar -xzf /tmp/private.tar.gz -C storage/app/private
chown -R www-data:www-data storage/app/public storage/app/private
rm /tmp/media.tar.gz /tmp/private.tar.gz
```

Run `tar -tzf` first if unsure, so the files do not land one directory too deep.
Delete whatever you exposed on the old server as soon as the transfer is done.

## 9. Check it end to end

- `https://<BACKEND_HOST>/admin/login` — the login page, **with its CSS**. If
  the styles are missing, the proxy headers are not getting through.
- Log in with an existing backoffice account.
- `https://<FRONTEND_HOST>/` — the site, with images.

---

# What needs a rebuild vs. a redeploy

| Change | Action |
|---|---|
| Backend PHP code, blade views, config | new `IMAGE_TAG` on the backend stack |
| Backend admin CSS/JS (`resources/sass`, `resources/js`) | `yarn prod` **locally**, commit `public/js` + `public/css`, then rebuild |
| Frontend Vue code | new `IMAGE_TAG` on the frontend stack |
| `API_URL`, `SITE_URL`, `LANGUAGE`, reCAPTCHA, GTAG, rate-limit header | edit the variable **and** set a new `IMAGE_TAG` on the frontend stack |
| Backend-only variables (mail, DB, Mailjet, HubSpot…) | edit the variable and redeploy — no rebuild |

Nuxt compiles its configuration into the bundle, which is why the frontend's
variables are the only ones that need an image rebuild.

The admin assets are **not** compiled in Docker on purpose: the toolchain
(laravel-mix 4 / webpack 4) cannot run on Node 17+, so `public/js/app.js` and
`public/css/app.css` are committed and shipped as-is, exactly like the old
server deploy did.

# Volumes (the data that must survive)

All three belong to the backend stack; the frontend is stateless.

| Volume | Contents |
|---|---|
| `db_data` | MySQL |
| `backend_media` | `storage/app/public` — uploaded media, served via `public/storage` |
| `backend_private` | `storage/app/private` — EIC form documents (company / VAT files) |

Everything else in the containers is disposable. Back these three up before any
risky change.

# Sibling EFET stacks

`efet-intranet` and `efet-edrs` deploy the same way. Give each its own
`IMAGE_NAME` — that is what keeps the image names apart and what each stack's
cleanup filters on, so they stay independent even when built from this same
repository.

# Troubleshooting

**The new commit is not live after redeploying** — the image was reused. Set a
new `IMAGE_TAG`; see section 6.

**`network web declared as external, but could not be found`** — the network
name is wrong for this server. Find the real one in Portainer → **Networks**
(it is the one with no stack) and set `TRAEFIK_NETWORK`. The same variable feeds
the `traefik.docker.network` labels, so nothing else needs editing.

**Admin panel loads without CSS, or "mixed content" errors** — Laravel is not
seeing the HTTPS from Traefik. Check `TRUSTED_PROXIES=*` and that `APP_URL` uses
`https://`.

**Frontend images 404 in production** — the backend host must be allowed for
`@nuxt/image`. It is derived from `BACKEND_URL` at build time; override with
`IMAGE_DOMAINS` and rebuild the frontend.

**Every frontend route 404s** — the database has no content yet. Import the dump.

**Build fails cloning the repo** — the token expired or lacks
`repository:read`, or the branch in *Reference* does not exist.

**Backend stuck on "Waiting for database"** — check `db` is healthy and that
`DB_DATABASE` / `DB_USERNAME` / `DB_PASSWORD` match between the two services
(they come from the same variables).

**Logs** — `LOG_CHANNEL=stderr` sends Laravel's log to the container output, so
it is readable in Portainer under the container's *Logs* tab.
