Skip to content

Conversation

@cggonzal
Copy link
Contributor

@cggonzal cggonzal commented Sep 22, 2025

📑

Follow on PR to implement the core logic addressed in issue 778.

@hwbrzzl I'm doing some final tests, but this PR is ready to start being reviewed. Unfortunately it is a bit bigger than I expected but a lot of it is comments and tests.

Once this PR is merged, I will create another PR into the docs repo in order to document this command alongside the compile documentation section that exists there.

The following is documented in the code but I want to put it here as well since it serves as a guide for anyone looking through the PR.

DeployCommand

Overview

This command implements a simple, opinionated deployment pipeline for Goravel applications.
It builds the application locally, performs a one-time remote server setup, uploads the
required artifacts to the server, restarts a systemd service, and supports rollback to the
previous binary. The goal is to provide a pragmatic, single-command deploy for small-to-medium
workloads.

This command only supports Debian-based (Ubuntu, Debian, etc.) based remote servers as well as MacOS and Linux based local hosts.

Architecture assumptions

Two primary deployment topologies are supported:

  1. Reverse proxy in front of the app (recommended)

    • reverseProxyEnabled=true
    • App listens on 127.0.0.1:<DEPLOY_APP_PORT> (e.g. 9000)
    • Caddy proxies public HTTP(S) traffic to the app
    • If reverseProxyTLSEnabled=true and a valid domain is configured, Caddy terminates TLS
      and automatically provisions certificates; otherwise Caddy serves plain HTTP on :80
  2. No reverse proxy

    • reverseProxyEnabled=false
    • App listens directly on :80 (APP_HOST=0.0.0.0, APP_PORT=80)

Artifacts & layout on server

Remote base directory: /var/www/<APP_NAME>
Files managed by this command on the remote host:

  • main : current binary (running)
  • main.prev : previous binary (standby for rollback)
  • .env : environment file (uploaded from DEPLOY_PROD_ENV_FILE_PATH)
  • public/ : optional static assets
  • storage/ : optional storage directory
  • resources/ : optional resources directory

Idempotency & first-time setup

The initial server setup is performed exactly once per server (per app name). The command first
checks if /etc/systemd/system/<APP_NAME>.service exists over SSH. If it exists, setup is skipped.
Otherwise, the command:

  • Installs and configures Caddy (only when reverseProxyEnabled=true)
  • Creates the app directory and sets ownership
  • Writes the systemd unit for <APP_NAME>
  • Enables the service and configures the firewall (ufw)

Subsequent deploys skip the setup entirely for speed and safety (unless --force-setup is used).
Note: If you change proxy/TLS/domain settings later, pass --force-setup to re-apply provisioning
changes (e.g., regenerate Caddyfile, adjust firewall rules, rewrite the unit file).

Rollback model

Every deployment that uploads a new binary preserves the previous one as main.prev. A rollback
simply swaps main and main.prev atomically and restarts the service. Non-binary assets (.env,
public, storage, resources) are not rolled back by this command.

Build & artifacts (local)

The command builds the binary (name: APP_NAME) using the configured target OS/ARCH and static
linking preference. See Goravel docs for compiling guidance, artifacts, and what to upload:
https://www.goravel.dev/getting-started/compile.html

Configuration (env)

Required:

  • app.name : Application name (used in remote paths/service name)
  • DEPLOY_IP_ADDRESS : Target server IP
  • DEPLOY_APP_PORT : Backend app port when reverse proxy is used (e.g. 9000)
  • DEPLOY_SSH_PORT : SSH port (e.g. 22)
  • DEPLOY_SSH_USER : SSH username (user must have sudo privileges)
  • DEPLOY_SSH_KEY_PATH : Path to SSH private key (e.g. ~/.ssh/id_rsa)
  • DEPLOY_OS : Target OS for build (e.g. linux)
  • DEPLOY_ARCH : Target arch for build (e.g. amd64)
  • DEPLOY_PROD_ENV_FILE_PATH : Local path to production .env file to upload

Optional / boolean flags (default false if unset):

  • DEPLOY_STATIC : Build statically when true
  • DEPLOY_REVERSE_PROXY_ENABLED : Use Caddy reverse proxy when true
  • DEPLOY_REVERSE_PROXY_TLS_ENABLED : Enable TLS (requires domain) when true
  • DEPLOY_DOMAIN : Domain name for TLS or HTTP vhost when using Caddy
    (required only if TLS is enabled)

CLI flags

  • --only : Comma-separated subset to deploy: main,env,public,storage,resources
  • -r, --rollback : Rollback to previous binary
  • -F, --force-setup : Force re-run of provisioning even if already set up

Security & firewall

The command uses SSH with StrictHostKeyChecking=no for convenience. For production, consider
manually trusting the host key to avoid MITM risks. Firewall rules are applied via ufw with
safe ordering: allow OpenSSH and required HTTP(S) ports first, then enable ufw to avoid losing
SSH connectivity.

Systemd service

The unit runs under DEPLOY_SSH_USER. Environment variables are provided via the unit for host/port,
and the working directory points to /var/www/<APP_NAME>. Service restarts are used (brief downtime).
For zero-downtime swaps, a more advanced process manager or socket activation would be required.

High-level deployment flow

  1. Build: compile the binary for the specified target (OS/ARCH, static optional) with name APP_NAME
  2. Determine artifacts to upload: main, .env, public, storage, resources (filter via --only)
  3. Setup (first deploy only, or when --force-setup):
    • Create directories and permissions
    • Install/configure Caddy based on reverse proxy + TLS settings
    • Write systemd unit and enable service
    • Configure ufw rules (OpenSSH, 80, and 443 as needed)
  4. Upload:
    • Binary: upload to main.new, move previous main to main.prev (if exists), atomically move main.new to main
    • .env: upload to .env.new, atomically move to .env
    • public, storage, resources: recursively upload if they exist locally
  5. Restart service: systemctl daemon-reload, then restart (or start) the service

Known limitations

  • No migrations or database orchestration
  • Rollback covers only the binary; assets/env are not rolled back
  • StrictHostKeyChecking is disabled by default for convenience
  • Changing proxy/TLS/domain requires --force-setup to re-apply provisioning
  • Assumes Debian/Ubuntu with apt-get and ufw available

Usage examples

Usage example (1 - with reverse proxy):

Assuming you have the following .env file stored in the root of your project as .env.production:

DEPLOY_IP_ADDRESS=68.183.170.32
DEPLOY_APP_PORT=9000
DEPLOY_SSH_PORT=22
DEPLOY_SSH_USER=root
DEPLOY_SSH_KEY_PATH=~/.ssh/id_ed25519
DEPLOY_OS=linux
DEPLOY_ARCH=amd64
DEPLOY_DOMAIN=www.testdeploys.com
DEPLOY_STATIC=true
DEPLOY_REVERSE_PROXY_ENABLED=true
DEPLOY_REVERSE_PROXY_TLS_ENABLED=true
DEPLOY_PROD_ENV_FILE_PATH=.env

You can then deploy your application to the server with the following command:

go run . artisan deploy

This will:

  1. Build the application
  2. On the remote server: install Caddy as a reverse proxy, support TLS, configure Caddy to proxy traffic to the application on port 9000, and only allow traffic from the domain www.test-deploys.com.
  3. On the remote server: install ufw, and set up the firewall to allow traffic to the application.
  4. On the remote server: create the systemd unit file and enable it
  5. Upload the application binary, environment file, public directory, storage directory, and resources directory to the server
  6. Restart the systemd service that manages the application

Usage example (2 - without reverse proxy):

You can also deploy without a reverse proxy by setting the DEPLOY_REVERSE_PROXY_ENABLED environment variable to false. For example,
assuming you have the following .env file stored in the root of your project as .env.production and you want to deploy your application to the server without a reverse proxy:

DEPLOY_IP_ADDRESS=68.183.170.32
DEPLOY_APP_PORT=80
DEPLOY_SSH_PORT=22
DEPLOY_SSH_USER=deploy
DEPLOY_SSH_KEY_PATH=~/.ssh/id_rsa
DEPLOY_OS=linux
DEPLOY_ARCH=amd64
DEPLOY_PROD_ENV_FILE_PATH=.env
DEPLOY_STATIC=true
DEPLOY_REVERSE_PROXY_ENABLED=false
DEPLOY_REVERSE_PROXY_TLS_ENABLED=false
DEPLOY_DOMAIN=

You can then deploy your application to the server with the following command:

go run . artisan deploy

This will:

  1. Build the application
  2. On the remote server: install ufw, and set up the firewall to allow traffic to the application that is listening on port 80 (http).
  3. On the remote server: create the systemd unit file and enable it
  4. Upload the application binary, environment file, public directory, storage directory, and resources directory to the server
  5. Restart the systemd service that manages the application

Usage example (3 - rollback):

You can also rollback a deployment to the previous binary by running the following command:

go run . artisan deploy --rollback

Usage example (4 - force setup):

You can also force the setup of the server by running the following command:

go run . artisan deploy --force-setup

Usage example (5 - only deploy subset of files):

You can also deploy only a subset of the files (such as only the main binary and the environment file) by running the following command:

go run . artisan deploy --only main,env

✅ Checks

  • Added test cases for my code
  • Deploy to a server with a reverse proxy, HTTPS, and domain
  • Deploy to a server without a reverse proxy where the app listens on Port 80
  • Deploy to a server when you only have a subset of the artifacts (only main, .env, resource, storage, public)
  • Deploy to a server then rollback the deploy

All server tests were done on Digital Ocean droplets that were on Ubuntu version 24.04 . However, any server service (AWS, GCP, Hertzner, etc.) that supports connecting to a Debian/Ubuntu server over SSH will work as well.

@cggonzal cggonzal requested a review from a team as a code owner September 22, 2025 04:27
@codecov
Copy link

codecov bot commented Sep 22, 2025

Codecov Report

❌ Patch coverage is 64.39394% with 141 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.90%. Comparing base (e7c38a7) to head (41b3454).
⚠️ Report is 4 commits behind head on master.

Files with missing lines Patch % Lines
console/console/deploy_command.go 63.63% 107 Missing and 33 partials ⚠️
console/service_provider.go 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1207      +/-   ##
==========================================
+ Coverage   67.67%   67.90%   +0.23%     
==========================================
  Files         250      250              
  Lines       14116    14452     +336     
==========================================
+ Hits         9553     9814     +261     
- Misses       4181     4224      +43     
- Partials      382      414      +32     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@cggonzal cggonzal mentioned this pull request Sep 22, 2025
1 task
@cggonzal
Copy link
Contributor Author

At the moment, this does not support running the command on a windows based system since the command assumes that the environment it is running in has access to the following CLI commands:

  • bash
  • ssh
  • scp

@cggonzal
Copy link
Contributor Author

@hwbrzzl I've completed the tests I plan on doing. I've listed the different tests that I did at the bottom of the description of this PR (above).

The code coverage can't hit the target of over 68% without having a dedicated server where you can run the full deployment test through every time. This does not seem worth it to me since I already have tests that cover the way the commands are being generated.

I'll go ahead and create a PR for the docs now that this is getting finalized.

@hwbrzzl
Copy link
Contributor

hwbrzzl commented Sep 25, 2025

Thanks @cggonzal, sorry for the delay, I'll review this PR today.

@hwbrzzl hwbrzzl closed this Sep 25, 2025
Copy link
Contributor

@hwbrzzl hwbrzzl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have run the command locally, and the process is amazing. I can visit the site successfully. Left more comments, and how about adding another command to revert the deployment? To clear the app, caddy, ufw, etc. We can create an issue to trace this.

@cggonzal
Copy link
Contributor Author

I have run the command locally, and the process is amazing. I can visit the site successfully. Left more comments, and how about adding another command to revert the deployment? To clear the app, caddy, ufw, etc. We can create an issue to trace this.

Happy to hear you like it. I'm glad it is useful to the project.

Adding another command to revert the deployment sounds good to me. I have created an issue here to discuss: goravel/goravel#804

@cggonzal
Copy link
Contributor Author

@hwbrzzl Sorry for the delay on this, I have been busier than expected lately.

I've gone ahead and made the changes you requested. I believe any further optimization / usability changes can be made in a later PR.

Once you review this and are okay with it, I believe we are good to merge.

I have also gone ahead and opened a PR for the config changes that would need to be added to the app.config. That PR should be merged in around the same time as this PR gets merged. Thankfully it is a small PR so it should be easy to merge.

Regarding documentation, I believe the best thing to do will be to merge in the documentation PR last so that it can be updated to include the final changes that were merged in.

@hwbrzzl
Copy link
Contributor

hwbrzzl commented Oct 27, 2025

Thanks @cggonzal , I'll check the latest commits.

@hwbrzzl hwbrzzl requested a review from Copilot October 29, 2025 07:30
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

sudo systemctl daemon-reload
sudo systemctl restart "$SERVICE" || sudo systemctl start "$SERVICE"
'`, opts.sshKeyPath, opts.sshPort, opts.sshUser, opts.sshIp, appDir, opts.appName)
return exec.Command("bash", "-lc", script)
Copy link

Copilot AI Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rollbackCommand function uses hardcoded 'bash' shell instead of calling makeLocalCommand(script) like other commands. This breaks cross-platform compatibility on Windows where the shell should be 'cmd /C'. Replace with makeLocalCommand(script) to ensure consistent platform-specific shell selection.

Suggested change
return exec.Command("bash", "-lc", script)
return makeLocalCommand(script)

Copilot uses AI. Check for mistakes.
Copy link
Contributor

@hwbrzzl hwbrzzl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your amazing work. I think it's good to go, given it's really a big PR now. We can optimize the remaining comments in another PR.

opts.domain = r.config.GetString("app.deploy.domain")
opts.prodEnvFilePath = r.config.GetString("app.deploy.prod_env_file_path")
opts.deployBaseDir = r.config.GetString("app.deploy.base_dir", "/var/www/")
opts.envDecryptKey = r.config.GetString("app.deploy.env_decrypt_key")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The key can be a command flag instead of a configuration. It should be secret.

},
&command.BoolFlag{
Name: "force-setup",
Aliases: []string{"f"},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Em, I mean the aliases can be removed directly.

return nil
if opts.remoteEnvDecrypt {
remoteDecrypt = true
remoteEncName = filepath.Base(opts.prodEnvFilePath)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
remoteEncName = filepath.Base(opts.prodEnvFilePath)
remoteEnvName = filepath.Base(opts.prodEnvFilePath)

opts.staticEnv = r.config.GetBool("app.build.static")
opts.reverseProxyEnabled = r.config.GetBool("app.deploy.reverse_proxy_enabled")
opts.reverseProxyTLSEnabled = r.config.GetBool("app.deploy.reverse_proxy_tls_enabled")
opts.remoteEnvDecrypt = r.config.GetBool("app.deploy.remote_env_decrypt")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be unnecessary, the prod env can be decrypted remotely by default. Otherwise, the local env file will be covered.

@hwbrzzl hwbrzzl merged commit 34d675d into goravel:master Oct 29, 2025
19 of 20 checks passed
hwbrzzl pushed a commit that referenced this pull request Oct 31, 2025
* start core logic implementation

* modify env example to have relevant deploy environment variables

* modify environment variables and deployment options

* refine server set up

* comment changes

* bug fixes from audit

* add comment with documentation at the top of deploy command file

* update comment

* update tests

* rearrange comment usage to bottom

* more comments tweaks

* clean up created files during tests

* add checks for runtime environment and scp, ssh, bash, dependencies

* test fixes

* windows tests bug fix

* increase code coverage on tests

* increase code coverage

* Revert "increase code coverage"

This reverts commit f3a4b91.

* Revert "increase code coverage on tests"

This reverts commit 6ab1db4.

* add missing env variable to example

* properly handle rollback based on deploy tests

* modify tests

* address comments

* remove domain nil check

* use existing file existence check function

* added EnvString() and EnvBool() functions

* introduce windows support

* increase test coverage for windows

* change DEPLOY_APP_PORT to DEPLOY_REVERSE_PROXY_PORT

* cahnge DEPLOY_IP_ADDRESS to DEPLOY_SSH_IP

* remove deployment configuration from .env file and instead put in the config of app

* make remote base directory a variable instead of forcing to /var/www

* stop mocking build command and just call it directly via CLI

* handle encrypted env file

* change getAllOptions to getDeployOptions

* change getWhichFilesToUpload() to getUploadOptions()

* make setupServerCommand only take in 1 value

* change parameters into commands so they are minimized

* mock config and mock context initialization

* add more deploy success and failure tests

* put config keys inside of app.deploy.*** and app.build.***

* update tests

* zip backups and .prev safety

* only upload storage on initial setup

* add tests for new EnvString() and EnvBool() config functions

* update deploy command documentation comment

* change ipAddress to sshIP and appPort to reverseProxyPort

* make rollback command take opts as the only parameter

* use artisan facade for build command

* use content to check if env file is encrypted

* modify isServerAlreadySetup() to take in opts as the only parameter

* remove os check

* update wording for local host validation

* modify test maybe checks

* resolve lint error

* add tests

* update tests

* fix lint error

* fix failing CI

* sort deployOptions struct alphabetically

* comment update

* modify error handling in validLocalHost

* remove unused param

* modify getDeployOptions error handling to not exit

* add tests for isEncryptedEnvContent function

* support env decrypt deploys

* change force-setup flag to --force

* get APP_ENV from .env file instead of putting it into systemd environment

* have app prefer the http.port for the reverse proxy setting

* address force setup comments

* allow caddy to support tls off mode
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants