[10.1] Continuous deployment to staging and production #34

Closed
opened 2026-06-14 00:34:18 +02:00 by dernbu · 0 comments
Owner

Parent

#10

Objective

Add continuous deployment from Forgejo Actions using a self-hosted runner so branch merges update the correct long-running Docker Compose environment.

Agreed decisions

  • Self-hosted Forgejo runner name: lawnet-deploy-01.
  • The runner should have a stable deployment label, for example lawnet-deploy.
  • Deploy production from pushes/merges to main.
  • Deploy staging from pushes/merges to dev.
  • Production web upstream host port: 3410.
  • Staging web upstream host port: 3411.
  • Caddy TLS termination and public reverse proxy configuration are outside this repository and will be configured manually.
  • Build images locally on the self-hosted runner from the checked-out repository.
  • Use Docker Compose for deployment.
  • Use separate Compose project names so staging and production get isolated containers, networks, and volumes.
  • Use lawnet_prod for production and lawnet_staging for staging.
  • Keep PostgreSQL, Flyway, scheduler, worker, and other internal services without host-published ports.
  • Only the web service publishes a loopback-bound host port.
  • Render target-specific dotenv env files during the workflow from individual Forgejo secrets plus non-secret target configuration.
  • Do not use one multiline dotenv secret per environment; keep each sensitive value as its own Forgejo secret so Forgejo log sanitization can mask each value independently.
  • Delete rendered env files after deployment as a cleanup step.
  • Use a repository just deploy <target> recipe as the deployment contract used by the workflow.
  • Run Flyway explicitly as a one-shot Compose lifecycle step with forced recreation, rather than relying on a plain long-running docker compose up -d to rerun an already-exited migration container.
  • Failure mode for the first cut: run Compose deployment best-effort and rely on Compose restart policy plus logs for diagnosis.
  • Add post-deploy health verification only after the web health endpoint is available and stable.

Deployment flow

The Forgejo workflow should select the target from the pushed branch:

main -> production -> .env.production -> COMPOSE_PROJECT_NAME/lawnet_prod -> 127.0.0.1:3410
 dev -> staging    -> .env.staging    -> COMPOSE_PROJECT_NAME/lawnet_staging -> 127.0.0.1:3411

The workflow should:

  1. Check out the repository on the self-hosted runner.
  2. Determine the deployment target from the branch name.
  3. Set target-specific non-secret config from workflow constants or repository variables.
  4. Render the selected target env file from individual Forgejo secrets and non-secret config without printing values to logs.
  5. Set restrictive permissions on the rendered env file while it exists.
  6. Run just deploy production or just deploy staging.
  7. Delete the rendered env file in a cleanup step that runs even if deployment fails.

Justfile deployment contract

Add a deploy target recipe that accepts at least:

just deploy production
just deploy staging

Aliases may be accepted for convenience:

production aliases: prod, main
staging aliases: dev

The recipe should map each target to:

production:
  env_file=.env.production
  project=lawnet_prod
  web_host_port=3410

staging:
  env_file=.env.staging
  project=lawnet_staging
  web_host_port=3411

The recipe should fail early if the target is unknown or the target env file is missing.

The deployment sequence should be equivalent to:

docker compose --env-file "$env_file" -p "$project" up -d postgres
docker compose --env-file "$env_file" -p "$project" up --build --force-recreate --exit-code-from flyway flyway
docker compose --env-file "$env_file" -p "$project" up -d --build --remove-orphans web data-ingest-scheduler data-ingest-worker

This keeps migration failure fatal to the workflow and ensures the Flyway one-shot container is recreated on every deploy.

Compose changes to implement

  • Make the web host port configurable from the environment, for example:
ports:
  - "127.0.0.1:${WEB_HOST_PORT:?WEB_HOST_PORT is required}:3000"
  • Keep the web container port controlled by WEB_PORT, defaulting to 3000 for the application process.
  • Do not publish host ports for PostgreSQL, Flyway, scheduler, worker, or any internal service.
  • Continue using Compose project names for isolation unless a later operational reason requires explicit volume names.
  • Verify that lawnet_prod and lawnet_staging produce separate PostgreSQL volumes, networks, and containers.

Flyway handling

For the first implementation, the critical requirement is explicit forced execution:

docker compose ... up --build --force-recreate --exit-code-from flyway flyway

A follow-up implementation improvement may add a repository-owned Flyway Dockerfile that copies migration scripts into the image instead of bind-mounting them. That would make the deployment artifact more immutable, but it is not a substitute for the explicit Flyway lifecycle step.

Forgejo secrets and variables

Use one Forgejo secret per sensitive value, per environment. Do not store a whole dotenv file as one secret.

Suggested secret names:

PRODUCTION_POSTGRES_PASSWORD
PRODUCTION_INTERNAL_API_TOKEN
STAGING_POSTGRES_PASSWORD
STAGING_INTERNAL_API_TOKEN

Suggested non-secret workflow constants or repository variables:

PRODUCTION_POSTGRES_DB=lawnet
PRODUCTION_POSTGRES_USER=lawnet
PRODUCTION_WEB_HOST_PORT=3410
PRODUCTION_WEB_PORT=3000

STAGING_POSTGRES_DB=lawnet_staging
STAGING_POSTGRES_USER=lawnet
STAGING_WEB_HOST_PORT=3411
STAGING_WEB_PORT=3000

The workflow writes only the selected target's values into the rendered env file.

Rendered production env file shape:

POSTGRES_DB=lawnet
POSTGRES_USER=lawnet
POSTGRES_PASSWORD=<PRODUCTION_POSTGRES_PASSWORD>
DATABASE_URL=postgresql://lawnet:<PRODUCTION_POSTGRES_PASSWORD>@postgres:5432/lawnet
WEB_PORT=3000
WEB_HOST_PORT=3410
INTERNAL_API_TOKEN=<PRODUCTION_INTERNAL_API_TOKEN>

Rendered staging env file shape:

POSTGRES_DB=lawnet_staging
POSTGRES_USER=lawnet
POSTGRES_PASSWORD=<STAGING_POSTGRES_PASSWORD>
DATABASE_URL=postgresql://lawnet:<STAGING_POSTGRES_PASSWORD>@postgres:5432/lawnet_staging
WEB_PORT=3000
WEB_HOST_PORT=3411
INTERNAL_API_TOKEN=<STAGING_INTERNAL_API_TOKEN>

DATABASE_URL must use the Compose service hostname postgres, not localhost, because application containers connect over the Compose backend network.

Password handling note: if the workflow interpolates the password into DATABASE_URL, the password should either be URL-safe or the workflow must URL-encode it without logging the encoded value. If URL encoding would produce a different value that Forgejo cannot mask from the original secret, avoid logging rendered env lines entirely.

Workflow requirements

  • Add a Forgejo Actions deployment workflow triggered by pushes to main and dev.
  • Route main to production and dev to staging.
  • Require the job to run on the self-hosted deployment runner label associated with lawnet-deploy-01.
  • Avoid printing secrets, derived secret values, or rendered env file contents.
  • Use shell cleanup/trap or an always-running cleanup step to delete the rendered env file after deployment.
  • Do not push images to a registry in the first cut.
  • Do not configure Caddy or TLS in the repository workflow.

Human follow-up TODOs

  • Register the self-hosted Forgejo runner as lawnet-deploy-01.
  • Give the runner a stable deployment label used by the workflow, for example lawnet-deploy.
  • Install Docker Engine and the Docker Compose plugin on the runner host.
  • Ensure the runner user can run Docker commands non-interactively.
  • Add the individual Forgejo secrets listed above.
  • Add any chosen non-secret repository variables, or confirm they should be constants in the workflow.
  • Create or protect the dev branch so merges to dev are intentional staging deployments.
  • Configure Caddy TLS termination manually with upstreams pointing at 127.0.0.1:3410 for production and 127.0.0.1:3411 for staging.
  • Confirm firewall rules expose only Caddy publicly, not the raw Compose web ports unless intentionally allowed.
  • Decide host backup/retention policy for the production PostgreSQL Docker volume.
  • Monitor disk usage on the runner host because local image builds and two database volumes will accumulate data.

Review checklist

  • Confirm main -> production -> 3410 and dev -> staging -> 3411 are correct.
  • Confirm deleting the rendered env file after every deployment is acceptable operationally.
  • Confirm the exact individual secret names.
  • Confirm non-secret target config should be workflow constants or Forgejo repository variables.
  • Confirm Compose project-name isolation is sufficient for PostgreSQL volumes.
  • Confirm Flyway should remain bind-mounted for the first implementation or be moved to a copied-migrations image in the same implementation PR.

Done when

  • A merge to dev deploys the staging stack on host port 3411.
  • A merge to main deploys the production stack on host port 3410.
  • Staging and production use separate env values, Compose projects, networks, and PostgreSQL volumes.
  • Flyway migrations run explicitly as part of the Compose deployment path and fail the deployment if migration fails.
  • Only the web service publishes a host port in each environment.
  • Each sensitive value is configured as an individual Forgejo secret, not as one multiline dotenv secret.
  • Rendered env files are deleted after deployment.
  • Deployment steps and human setup requirements are documented.
## Parent #10 ## Objective Add continuous deployment from Forgejo Actions using a self-hosted runner so branch merges update the correct long-running Docker Compose environment. ## Agreed decisions - Self-hosted Forgejo runner name: `lawnet-deploy-01`. - The runner should have a stable deployment label, for example `lawnet-deploy`. - Deploy production from pushes/merges to `main`. - Deploy staging from pushes/merges to `dev`. - Production web upstream host port: `3410`. - Staging web upstream host port: `3411`. - Caddy TLS termination and public reverse proxy configuration are outside this repository and will be configured manually. - Build images locally on the self-hosted runner from the checked-out repository. - Use Docker Compose for deployment. - Use separate Compose project names so staging and production get isolated containers, networks, and volumes. - Use `lawnet_prod` for production and `lawnet_staging` for staging. - Keep PostgreSQL, Flyway, scheduler, worker, and other internal services without host-published ports. - Only the `web` service publishes a loopback-bound host port. - Render target-specific dotenv env files during the workflow from individual Forgejo secrets plus non-secret target configuration. - Do not use one multiline dotenv secret per environment; keep each sensitive value as its own Forgejo secret so Forgejo log sanitization can mask each value independently. - Delete rendered env files after deployment as a cleanup step. - Use a repository `just deploy <target>` recipe as the deployment contract used by the workflow. - Run Flyway explicitly as a one-shot Compose lifecycle step with forced recreation, rather than relying on a plain long-running `docker compose up -d` to rerun an already-exited migration container. - Failure mode for the first cut: run Compose deployment best-effort and rely on Compose restart policy plus logs for diagnosis. - Add post-deploy health verification only after the web health endpoint is available and stable. ## Deployment flow The Forgejo workflow should select the target from the pushed branch: ```text main -> production -> .env.production -> COMPOSE_PROJECT_NAME/lawnet_prod -> 127.0.0.1:3410 dev -> staging -> .env.staging -> COMPOSE_PROJECT_NAME/lawnet_staging -> 127.0.0.1:3411 ``` The workflow should: 1. Check out the repository on the self-hosted runner. 2. Determine the deployment target from the branch name. 3. Set target-specific non-secret config from workflow constants or repository variables. 4. Render the selected target env file from individual Forgejo secrets and non-secret config without printing values to logs. 5. Set restrictive permissions on the rendered env file while it exists. 6. Run `just deploy production` or `just deploy staging`. 7. Delete the rendered env file in a cleanup step that runs even if deployment fails. ## Justfile deployment contract Add a `deploy target` recipe that accepts at least: ```sh just deploy production just deploy staging ``` Aliases may be accepted for convenience: ```text production aliases: prod, main staging aliases: dev ``` The recipe should map each target to: ```text production: env_file=.env.production project=lawnet_prod web_host_port=3410 staging: env_file=.env.staging project=lawnet_staging web_host_port=3411 ``` The recipe should fail early if the target is unknown or the target env file is missing. The deployment sequence should be equivalent to: ```sh docker compose --env-file "$env_file" -p "$project" up -d postgres docker compose --env-file "$env_file" -p "$project" up --build --force-recreate --exit-code-from flyway flyway docker compose --env-file "$env_file" -p "$project" up -d --build --remove-orphans web data-ingest-scheduler data-ingest-worker ``` This keeps migration failure fatal to the workflow and ensures the Flyway one-shot container is recreated on every deploy. ## Compose changes to implement - Make the web host port configurable from the environment, for example: ```yaml ports: - "127.0.0.1:${WEB_HOST_PORT:?WEB_HOST_PORT is required}:3000" ``` - Keep the web container port controlled by `WEB_PORT`, defaulting to `3000` for the application process. - Do not publish host ports for PostgreSQL, Flyway, scheduler, worker, or any internal service. - Continue using Compose project names for isolation unless a later operational reason requires explicit volume names. - Verify that `lawnet_prod` and `lawnet_staging` produce separate PostgreSQL volumes, networks, and containers. ## Flyway handling For the first implementation, the critical requirement is explicit forced execution: ```sh docker compose ... up --build --force-recreate --exit-code-from flyway flyway ``` A follow-up implementation improvement may add a repository-owned Flyway Dockerfile that copies migration scripts into the image instead of bind-mounting them. That would make the deployment artifact more immutable, but it is not a substitute for the explicit Flyway lifecycle step. ## Forgejo secrets and variables Use one Forgejo secret per sensitive value, per environment. Do not store a whole dotenv file as one secret. Suggested secret names: ```text PRODUCTION_POSTGRES_PASSWORD PRODUCTION_INTERNAL_API_TOKEN STAGING_POSTGRES_PASSWORD STAGING_INTERNAL_API_TOKEN ``` Suggested non-secret workflow constants or repository variables: ```text PRODUCTION_POSTGRES_DB=lawnet PRODUCTION_POSTGRES_USER=lawnet PRODUCTION_WEB_HOST_PORT=3410 PRODUCTION_WEB_PORT=3000 STAGING_POSTGRES_DB=lawnet_staging STAGING_POSTGRES_USER=lawnet STAGING_WEB_HOST_PORT=3411 STAGING_WEB_PORT=3000 ``` The workflow writes only the selected target's values into the rendered env file. Rendered production env file shape: ```dotenv POSTGRES_DB=lawnet POSTGRES_USER=lawnet POSTGRES_PASSWORD=<PRODUCTION_POSTGRES_PASSWORD> DATABASE_URL=postgresql://lawnet:<PRODUCTION_POSTGRES_PASSWORD>@postgres:5432/lawnet WEB_PORT=3000 WEB_HOST_PORT=3410 INTERNAL_API_TOKEN=<PRODUCTION_INTERNAL_API_TOKEN> ``` Rendered staging env file shape: ```dotenv POSTGRES_DB=lawnet_staging POSTGRES_USER=lawnet POSTGRES_PASSWORD=<STAGING_POSTGRES_PASSWORD> DATABASE_URL=postgresql://lawnet:<STAGING_POSTGRES_PASSWORD>@postgres:5432/lawnet_staging WEB_PORT=3000 WEB_HOST_PORT=3411 INTERNAL_API_TOKEN=<STAGING_INTERNAL_API_TOKEN> ``` `DATABASE_URL` must use the Compose service hostname `postgres`, not `localhost`, because application containers connect over the Compose backend network. Password handling note: if the workflow interpolates the password into `DATABASE_URL`, the password should either be URL-safe or the workflow must URL-encode it without logging the encoded value. If URL encoding would produce a different value that Forgejo cannot mask from the original secret, avoid logging rendered env lines entirely. ## Workflow requirements - Add a Forgejo Actions deployment workflow triggered by pushes to `main` and `dev`. - Route `main` to production and `dev` to staging. - Require the job to run on the self-hosted deployment runner label associated with `lawnet-deploy-01`. - Avoid printing secrets, derived secret values, or rendered env file contents. - Use shell cleanup/trap or an always-running cleanup step to delete the rendered env file after deployment. - Do not push images to a registry in the first cut. - Do not configure Caddy or TLS in the repository workflow. ## Human follow-up TODOs - Register the self-hosted Forgejo runner as `lawnet-deploy-01`. - Give the runner a stable deployment label used by the workflow, for example `lawnet-deploy`. - Install Docker Engine and the Docker Compose plugin on the runner host. - Ensure the runner user can run Docker commands non-interactively. - Add the individual Forgejo secrets listed above. - Add any chosen non-secret repository variables, or confirm they should be constants in the workflow. - Create or protect the `dev` branch so merges to `dev` are intentional staging deployments. - Configure Caddy TLS termination manually with upstreams pointing at `127.0.0.1:3410` for production and `127.0.0.1:3411` for staging. - Confirm firewall rules expose only Caddy publicly, not the raw Compose web ports unless intentionally allowed. - Decide host backup/retention policy for the production PostgreSQL Docker volume. - Monitor disk usage on the runner host because local image builds and two database volumes will accumulate data. ## Review checklist - Confirm `main -> production -> 3410` and `dev -> staging -> 3411` are correct. - Confirm deleting the rendered env file after every deployment is acceptable operationally. - Confirm the exact individual secret names. - Confirm non-secret target config should be workflow constants or Forgejo repository variables. - Confirm Compose project-name isolation is sufficient for PostgreSQL volumes. - Confirm Flyway should remain bind-mounted for the first implementation or be moved to a copied-migrations image in the same implementation PR. ## Done when - A merge to `dev` deploys the staging stack on host port `3411`. - A merge to `main` deploys the production stack on host port `3410`. - Staging and production use separate env values, Compose projects, networks, and PostgreSQL volumes. - Flyway migrations run explicitly as part of the Compose deployment path and fail the deployment if migration fails. - Only the web service publishes a host port in each environment. - Each sensitive value is configured as an individual Forgejo secret, not as one multiline dotenv secret. - Rendered env files are deleted after deployment. - Deployment steps and human setup requirements are documented.
dernbu added spent time 2026-06-14 04:26:18 +02:00
45 minutes
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Total time spent: 45 minutes
dernbu
45 minutes
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
dernbu/lawnet-scraper#34
No description provided.